From 1d19177f79c09c6fd574a4e5daed3ed0d11206f2 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 16 Mar 2026 15:58:10 -0500 Subject: [PATCH 01/49] rfcs: add RFC-18 link classification flex-algo Introduces onchain link color model using IS-IS Flexible Algorithm (RFC 9350) to separate VPN unicast and multicast forwarding topologies. Defines LinkColorInfo PDA, link_color field on Link, FlexAlgo feature flag, and controller changes for admin-group tagging, flex-algo definitions, system-colored-tunnel-rib BGP resolution, and per-tunnel color extended community stamping. Co-Authored-By: Claude Sonnet 4.6 --- rfcs/rfc18-link-classification-flex-algo.md | 581 ++++++++++++++++++++ 1 file changed, 581 insertions(+) create mode 100644 rfcs/rfc18-link-classification-flex-algo.md diff --git a/rfcs/rfc18-link-classification-flex-algo.md b/rfcs/rfc18-link-classification-flex-algo.md new file mode 100644 index 0000000000..f6bf2d58e9 --- /dev/null +++ b/rfcs/rfc18-link-classification-flex-algo.md @@ -0,0 +1,581 @@ +# RFC-18: Link Classification — Flex-Algo + +## Summary + +**Status: `Draft`** + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119). + +DoubleZero contributors operate links with different physical characteristics — low latency, high bandwidth, or both. Today all traffic uses the same IS-IS topology, so every service follows the same paths regardless of what those paths are optimized for. This RFC introduces a link classification model that allows DZF to assign named color labels to links onchain and use IS-IS Flexible Algorithm (flex-algo) to compute separate constraint-based forwarding topologies per color. Different traffic classes — VPN unicast and IP multicast — can then use different topologies. + +**Deliverables:** +- `LinkColorInfo` onchain account — DZF creates this to define a color, with auto-assigned admin-group bit, flex-algo number, and derived EOS color value +- `link_color` field on the serviceability link account — references the assigned color +- `FlexAlgo` feature flag (bit 2) in the existing `GlobalState.feature_flags` bitmask — gates all flex-algo device config; must be explicitly enabled by DZF before the controller pushes any flex-algo configuration to devices +- Controller logic — translates colors into IS-IS TE admin-groups on interfaces, generates flex-algo topology definitions, configures `system-colored-tunnel-rib` as the BGP next-hop resolution source, and applies BGP color extended community route-maps per VRF; all conditioned on the `FlexAlgo` feature flag + +**Scope:** +- Delivers traffic-class-level segregation: multicast vs. VPN unicast at the network level +- All unicast tenants share a single constrained topology today — the architecture is forward-compatible with per-tenant path differentiation without rework +- Per-tenant steering (directing one tenant to a different constrained topology) requires adding a `topology_color` field to the `Tenant` account — deferred to a future RFC that builds on the link color model defined here + +--- + +## Motivation + +The DoubleZero network carries two distinct traffic types today: VPN unicast (tenants connected in IBRL mode) and IP multicast. Both follow the same IS-IS topology, where link metrics are derived from measured latency. Every service takes the lowest-latency path — there is no differentiation. + +As the network grows, traffic types have different requirements. Latency-sensitive multicast benefits from low-latency links. Higher-latency, higher-bandwidth links that do not win the latency-based SPF are chronically underutilized — yet they may be exactly what certain tenants need. Business requirements, not just latency, should determine which traffic uses which links. A single shared topology cannot serve both simultaneously. This RFC solves the first layer of this problem: separating traffic by class (multicast vs. unicast) at the network level, so that a set of links can be reserved for multicast use while unicast routes around them. Per-tenant path differentiation — where individual tenant VRFs are steered onto different constrained topologies — is a distinct problem addressed architecturally here but deferred in implementation. + +Without a steering mechanism, all tenants compete for the same links, and contributors have no way to express that a link is intended for a particular class of traffic. The result is no way to differentiate service quality as the network scales. + +IS-IS Flexible Algorithm provides the routing mechanism: each flex-algo defines a constrained topology using TE admin-groups as include/exclude criteria. What does not yet exist is a way to assign admin-group membership to links onchain, so the controller can apply the correct device configuration automatically rather than requiring per-device manual config. This RFC defines that model. + +--- + +## New Terminology + +- **Link color** — A DZF-defined label assigned to a link that maps to an IS-IS TE admin-group. Determines which flex-algo topologies include or exclude the link. +- **Admin-group** — An IS-IS TE attribute assigned to a physical interface that flex-algo algorithms use as include/exclude constraints. Also called "affinity" in some implementations. Arista EOS supports bits 0–127. +- **Flex-algo** — IS-IS Flexible Algorithm (RFC 9350). Each algorithm defines a constrained topology (metric type + admin-group include/exclude rules) and computes an independent SPF. Nodes with the same flex-algo compute consistent paths across the topology. Arista EOS supports flex-algo numbers 128–255. +- **EOS color value** — An integer assigned to a flex-algo definition in EOS (`color ` under `flex-algo`). Causes EOS to install that algorithm's computed tunnels in `system-colored-tunnel-rib` keyed by (endpoint, color). Derived as `admin_group_bit + 1`; not stored separately. +- **system-colored-tunnel-rib** — An EOS system RIB auto-populated when flex-algo definitions carry a `color` field. Keyed by (endpoint, color). Used by BGP next-hop resolution to steer VPN routes onto constrained topologies based on the BGP color extended community carried on the route. +- **BGP color extended community** — A BGP extended community (`Color:CO(00):`) set on VPN-IPv4 routes inbound on the client-facing BGP session. The color value matches the EOS flex-algo color, enabling per-route algorithm selection at devices receiving the route via VPN-IPv4. +- **UNICAST-DEFAULT** — The first color DZF creates. Auto-assigned admin-group bit 0, flex-algo 128, EOS color value 1. Applied to all links eligible for the default unicast topology. Flex-algo 128 uses `include-any UNICAST-DEFAULT`, so only explicitly tagged links participate in the unicast topology. Untagged links are excluded from unicast but remain available to multicast via IS-IS algo 0. +- **Link color constraint** — Each `LinkColorInfo` defines either an `IncludeAny` or `Exclude` constraint. `IncludeAny`: only links explicitly tagged with this color participate in the topology. `Exclude`: all links except those tagged with this color participate. UNICAST-DEFAULT uses `IncludeAny`. +- **FlexAlgo feature flag** — Bit 2 in `GlobalState.feature_flags`. When unset, the controller generates no flex-algo config regardless of how many `LinkColorInfo` accounts exist or how many links are tagged. Allows DZF to prepare onchain state independently of device rollout timing. + +--- + +## Scope and Limitations + +| Scenario | This RFC | Notes | +|---|---|---| +| Default unicast topology via UNICAST-DEFAULT color | ✅ | Core deliverable; all unicast-eligible links must be explicitly tagged | +| Multicast uses all links (algo 0) | ✅ | Natural PIM RPF behavior; includes both tagged and untagged links; no config required | +| Multiple links with the same color | ✅ | All tagged links participate together in the constrained topology | +| New links excluded from unicast by default | ✅ | `include-any` strictly excludes untagged links — verified in lab. New links must be explicitly tagged before they carry unicast traffic | +| Per-tenant unicast path differentiation | ⚠️ | Architecture proven in lab (BGP color extended communities + `system-colored-tunnel-rib`). Today all unicast tenants share one color. Per-tenant steering requires `topology_color` on `Tenant` account — deferred to a future RFC | +| Exclude a link from multicast | ❌ | PIM RPF uses IS-IS algo 0 unconditionally. No EOS mechanism can redirect multicast away from specific links within the current architecture | +| Automated link selection by bandwidth or type | ❌ | Link tagging is manual DZF policy at this stage. `link.bandwidth` and `link.link_type` exist onchain and can drive automated selection in a future RFC | + +The ❌ limitations are architectural, not implementation gaps in this RFC. The multicast exclusion limitation is fundamental to PIM RPF and is not addressable without a different multicast architecture (e.g., MVPN). Automated link selection is deferred until per-tenant topologies (e.g., Shelby) require it. + +**Shelby bandwidth assumption:** The Shelby topology, when implemented in a future RFC, will be built from links tagged with the SHELBY admin-group, selected based on 100Gbps physical capacity. That RFC will assume the full 100Gbps of each qualifying link is available to Shelby traffic — no bandwidth reservation or admission control is enforced. Whether capacity sharing, reservation, or isolation is appropriate for Shelby is deferred to that RFC. + +--- + +## Alternatives Considered + +### Do nothing +Continue relying on a single IS-IS topology for all traffic. All services — VPN unicast and IP multicast — share the same paths, competing for the same links. Contributors have no way to express that a link is intended for a particular traffic class. This is the current state of the production network. It is rejected because traffic class differentiation is a stated requirement as the network grows: latency-sensitive multicast and unicast tenants with different path requirements cannot both be optimally served by the same topology. + +### vpn-unicast-rib (rejected) +An alternative design considered during development used a user-defined `vpn-unicast-rib` with `source-protocol isis flex-algo preference 50` to steer VPN unicast onto constrained topologies. This approach works for a single shared topology but is architecturally incompatible with per-tenant path differentiation: adding `color` to a flex-algo definition (required for per-route algorithm selection via BGP color extended communities) moves tunnels from `system-tunnel-rib` to `system-colored-tunnel-rib`, making them invisible to a user-defined tunnel RIB. The two approaches are mutually exclusive. This RFC commits to the `system-colored-tunnel-rib` approach to avoid a future rework. + +### Future paths + +Three mechanisms are deferred. Each addresses a distinct escalation in steering granularity beyond what this RFC delivers: + +| Mechanism | Granularity | Solves | Complexity | Trigger to adopt | +|---|---|---|---|---| +| Per-tenant flex-algo color | Per-tenant VRF | Different constrained topology per tenant | Low — add `topology_color` to `Tenant` account; controller generates per-VRF route-map with matching color | First tenant requiring path isolation from the default unicast topology | +| CBF with non-default VRFs | Per-tenant VRF, per-DSCP | Different constrained topology per tenant with DSCP-based sub-steering | Medium — TCAM profile change; builds on flex-algo colors defined here | First tenant requiring DSCP-level path differentiation within a VRF | +| SR-TE | Per-prefix or per-flow | Explicit path control with segment lists; per-prefix or per-DSCP steering independent of IGP topology | High — controller must compute or define explicit segment lists per policy, and set BGP Color Extended Community on routes per-tenant | Per-prefix SLA requirements, or when per-tenant flex-algo color is insufficient | +| RSVP-TE | Per-LSP (P2P unicast) or per-tree (P2MP multicast) | Hard bandwidth reservation with admission control | High — RSVP-TE on all path devices, IS-IS TE bandwidth advertisement, controller logic to provision per-tenant tunnel interfaces | SLA-backed bandwidth guarantees where admission control is required, not just path preference | + +The lowest-cost next step for per-tenant differentiation is adding `topology_color: Option` to the `Tenant` account. The controller reads it, resolves the `LinkColorInfo` PDA, and generates a VRF-specific route-map with the corresponding color value. No new network infrastructure is required — it builds directly on the `system-colored-tunnel-rib` mechanism defined here. + +--- + +## Detailed Design + +### Link Color Model + +#### LinkColorInfo account + +DZF creates a `LinkColorInfo` PDA per color. It stores the color's name and auto-assigned routing parameters. The program MUST auto-assign the next available admin-group bit (starting at 0) and the corresponding flex-algo number and EOS color value using the formula: + +``` +admin_group_bit = next available bit in 0–127 +flex_algo_number = 128 + admin_group_bit +eos_color_value = admin_group_bit + 1 (derived, not stored) +``` + +This formula ensures the admin-group bit, flex-algo number, and EOS color value are always in the EOS-supported ranges (bits 0–127, algos 128–255, color 1–4294967295) and are derived consistently from each other. The EOS color value is not stored onchain — it is computed by the controller wherever needed. + +```rust +#[derive(BorshSerialize, BorshDeserialize, Debug)] +pub enum LinkColorConstraint { + IncludeAny = 0, // only tagged links participate in the topology + Exclude = 1, // all links except tagged participate in the topology +} + +#[derive(BorshSerialize, BorshDeserialize, Debug)] +pub struct LinkColorInfo { + pub name: String, // e.g. "unicast-default" + pub admin_group_bit: u8, // auto-assigned, 0–127 + pub flex_algo_number: u8, // auto-assigned, 128–255; always 128 + admin_group_bit + pub constraint: LinkColorConstraint, // IncludeAny or Exclude +} +``` + +PDA seeds: `[b"link-color-info", name.as_bytes()]`. `LinkColorInfo` accounts MUST only be created or updated by foundation keys. + +Name length MUST NOT exceed 32 bytes, enforced by the program on `create`. This keeps PDA seeds well within the 32-byte limit and ensures the admin-group alias name is reasonable in EOS config. + +The program MUST validate `admin_group_bit <= 127` on `create` and MUST return an explicit error if all 128 slots are exhausted. This is a hard constraint: EOS supports bits 0–127 only, and `128 + 127 = 255` is the maximum representable value in `flex_algo_number: u8`. + +Admin-group bits from deleted colors MUST NOT be reused. Color deletion is not supported in this RFC, so this constraint applies to any future deletion implementation: reusing a bit before all devices have had their config updated would cause those devices to apply the new color's constraints to interfaces still carrying the old bit's admin-group. At current scale (128 available slots), exhaustion is not a practical concern. + +#### link_color field on Link + +A `link_color` field is added to the serviceability `Link` account. It holds the pubkey of the `LinkColorInfo` PDA for the assigned color, or `Pubkey::default()` if no color is assigned. The field appends to the end of the serialized layout, defaulting to `Pubkey::default()` on existing accounts. + +`link_color` MUST only be set by keys in the DZF foundation allowlist. Contributors MUST NOT set this field. Link tagging is a DZF policy decision — there is no automated selection based on `link.bandwidth` or `link.link_type` at this stage. + +```rust +// Foundation-only fields +if globalstate.foundation_allowlist.contains(payer_account.key) { + if let Some(link_color) = value.link_color { + link.link_color = link_color; + } +} +``` + +#### CLI + +**Color lifecycle:** + +``` +doublezero link color create --name --constraint +doublezero link color update --name +doublezero link color delete --name +doublezero link color clear --name +doublezero link color list +``` + +- `create` — creates a `LinkColorInfo` PDA; auto-assigns the next available admin-group bit and flex-algo number; stores the specified constraint (`include-any` or `exclude`). MUST fail if the name already exists. If the `FlexAlgo` feature flag is set, the controller will include this color in the `router traffic-engineering` block, IS-IS TE advertisement, and BGP next-hop resolution config on the next reconciliation cycle. No immediate device impact if the `FlexAlgo` feature flag is not set. +- `update` — reserved for future use; all fields are immutable after creation. No device config change. +- `delete` — removes the `LinkColorInfo` PDA onchain. MUST fail if any link still references this color (use `clear` first). **Device-side cleanup is deferred** — the controller does not generate `no` commands to remove the admin-group alias, flex-algo definition, IS-IS TE advertisement, or BGP next-hop resolution config from devices when a color is deleted. See below. +- `clear` — removes this color from all links currently assigned to it, setting their `link_color` to `Pubkey::default()`. This is a multi-transaction sweep — one `LinkUpdateArgs` instruction is submitted per assigned link; it is not atomic. On the next reconciliation cycle, the controller generates `no traffic-engineering administrative-group ` on all previously-colored interfaces. +- `list` — fetches all `LinkColorInfo` accounts and all `Link` accounts and groups links by color: + +**Device-side cleanup on deletion is deferred.** The `delete` instruction removes the `LinkColorInfo` PDA onchain. The controller does not generate removal commands (`no administrative-group alias`, `no flex-algo`, etc.) when a color is deleted — device config is not cleaned up automatically. Safe device-side deletion would require the controller to surgically remove the admin-group alias, flex-algo definition, IS-IS TE advertisement, loopback node-segment, and BGP next-hop resolution config from all devices without disrupting surviving colors. EOS behavior complicates this: `no traffic-engineering administrative-group ` removes **all** admin-groups from the interface regardless of which name is specified — not just the named one. An interface that should retain a second color after deletion would need the surviving color re-applied atomically in the same reconciliation pass. The correct sequencing across a distributed reconciliation cycle has not been validated. Device cleanup on deletion is therefore deferred to a future enhancement. Colors SHOULD be treated as long-lived; the 128-slot limit is not a practical constraint at current scale. + +``` +NAME CONSTRAINT FLEX-ALGO ADMIN-GROUP BIT EOS COLOR LINKS +default — — — — link-abc123, link-def456 +unicast-default include-any 128 0 1 link-xyz789 +``` + +**Link color assignment:** + +``` +doublezero link update --pubkey --link-color +doublezero link update --pubkey --link-color default +doublezero link update --code --link-color +``` + +- `--link-color ` MUST resolve the color name to the corresponding `LinkColorInfo` PDA pubkey before submitting the instruction — the onchain field stores a pubkey, not a name. +- `--link-color default` sets `link_color` to `Pubkey::default()`, removing any color assignment. +- `doublezero link get` and `doublezero link list` MUST include `link_color` in their output, showing the resolved color name (or "default"). + +--- + +### FlexAlgo Feature Flag + +The `FeatureFlag` enum in `smartcontract/programs/doublezero-serviceability/src/state/feature_flags.rs` already provides a network-wide bitmask mechanism in `GlobalState`. This RFC adds bit 2: + +```rust +pub enum FeatureFlag { + OnChainAllocation = 0, + RequirePermissionAccounts = 1, + FlexAlgo = 2, // new +} +``` + +Only foundation keys can set this flag via the existing `SetFeatureFlags` instruction. The controller reads `GlobalState.feature_flags` from `ProgramData` on each reconciliation cycle and exposes it to the template as `.Features.FlexAlgo`. + +**Rollout sequence:** + +The `FlexAlgo` feature flag decouples onchain state preparation from device deployment. DZF can create colors and tag links before any device receives flex-algo config, then enable the flag when the network is ready. + +1. DZF creates `LinkColorInfo` accounts and tags links with `link_color` — no device impact while the flag is unset +2. When ready to deploy to the network, DZF calls `SetFeatureFlags` with `FlexAlgo = true` +3. On the next reconciliation cycle, the controller generates and pushes all flex-algo config to all devices simultaneously + +**All controller template blocks introduced by this RFC are conditioned on `and .Features.FlexAlgo .LinkColors`.** When the flag is unset, devices receive identical config to today regardless of onchain link color state. + +### IS-IS Flex-Algo Topology + +Each link color maps to an IS-IS TE admin-group bit via the `LinkColorInfo` account. The controller MUST read `link.link_color`, resolve the `LinkColorInfo` PDA, and apply the corresponding admin-group to the physical interface. + +| Link color | Constraint | Admin-group bit | Flex-algo number | EOS color value | Topology | +|---|---|---|---|---|---| +| (untagged) | — | — | — (algo 0) | — | All links | +| unicast-default | include-any | 0 | 128 | 1 | Only UNICAST-DEFAULT tagged links | +| (future color) | include-any or exclude | 1 | 129 | 2 | Defined by constraint | + +The flex-algo definition MUST be configured on each DZD by the controller. The `color` field MUST be included and set to `admin_group_bit + 1`. The constraint type determines whether `include any` or `exclude` is used. Using UNICAST-DEFAULT as an example: + +``` +router traffic-engineering + administrative-group alias UNICAST-DEFAULT group 0 + flex-algo + flex-algo 128 unicast-default + administrative-group include any 0 + color 1 +``` + +Flex-algo 128 ("unicast-default") computes an IS-IS SPF over only those links tagged `UNICAST-DEFAULT`. The `color 1` field causes EOS to install these tunnels in `system-colored-tunnel-rib` keyed by (endpoint, 1). Devices that participate in flex-algo 128 advertise both an algo-0 node-segment and an algo-128 node-segment via their loopback. + +**Operational implication:** Every link intended to carry unicast traffic MUST be explicitly tagged `UNICAST-DEFAULT`. Links added to the network are excluded from the unicast topology by default until DZF assigns the color. + +#### Universal participation requirement + +Flex-algo MUST be enabled on every device in the network, not only on devices that have colored links. A device that does not participate in a flex-algo does not advertise a node-SID for that algorithm, so other devices cannot include it in the constrained SPF and cannot steer VPN traffic to it via the constrained topology. VPN routes to a non-participating device will not resolve via the colored tunnel RIB and will fall back to the next resolution source. The controller MUST therefore push the flex-algo definitions and BGP next-hop resolution config to all devices unconditionally. Admin-group tagging on interfaces MUST only be applied to links with a non-default color. + +#### Multicast path isolation + +Multicast (PIM) resolves via the IS-IS unicast RIB (algo 0), which uses all links regardless of color. This is inherent to how PIM RPF works — it is not affected by the BGP next-hop resolution profile, and `next-hop resolution ribs` does not support multicast address families. Multicast isolation does not depend on any additional configuration — PIM RPF resolves via the unicast RIB regardless of how VPN unicast is steered. + +| Service | Path | UNICAST-DEFAULT links used? | +|---|---|---| +| VPN unicast | flex-algo 128 (`system-colored-tunnel-rib`, color 1) | Yes — only tagged links | +| Multicast (PIM, default VRF) | IS-IS algo 0 (unicast RIB) | Yes — all links including tagged | + +--- + +### BGP Color Extended Community + +VPN-IPv4 routes MUST carry a BGP color extended community (`Color:CO(00):`) on export. The color value matches the EOS flex-algo `color` field for the target topology. At receiving devices, BGP next-hop resolution uses `system-colored-tunnel-rib` to match the (next-hop, color) pair to a flex-algo tunnel. + +#### Next-hop resolution + +All devices MUST be configured with the following BGP next-hop resolution profile: + +``` +router bgp 65342 + address-family vpn-ipv4 + next-hop resolution ribs tunnel-rib colored system-colored-tunnel-rib system-connected +``` + +`system-colored-tunnel-rib` is auto-populated by EOS when flex-algo definitions carry a `color` field. A VPN route carrying `Color:CO(00):1` resolves its next-hop through the color-1 (unicast-default, algo 128) tunnel to that endpoint. + +#### Inbound route-map color stamping + +The controller already generates a `RM-USER-{{ .Id }}-IN` route-map per tunnel, applied inbound on each client-facing BGP session. This route-map currently sets standard communities identifying the user as unicast or multicast and tagging the originating exchange. The color extended community is added as an additional `set` statement in this same route-map, applied only to unicast tunnels and only when link colors are defined: + +``` +route-map RM-USER-{{ .Id }}-IN permit 10 + match ip address prefix-list PL-USER-{{ .Id }} + match as-path length = 1 + set community 21682:{{ if eq true .IsMulticast }}1300{{ else }}1200{{ end }} 21682:{{ $.Device.BgpCommunity }} + {{- if and $.Features.FlexAlgo (not .IsMulticast) $.LinkColors }} + set extcommunity color {{ .TenantTopologyEosColorValue }} + {{- end }} +``` + +`.TenantTopologyEosColorValue` is resolved by the controller from the tunnel's tenant: if the tenant has a `topology_color` pubkey set, resolve the `LinkColorInfo` and compute `AdminGroupBit + 1`; otherwise use the default unicast color (color 1, the first `LinkColorInfo` by `AdminGroupBit`). Multicast tunnels do not receive the color community — multicast RPF resolves via IS-IS algo 0 and does not use `system-colored-tunnel-rib`. + +Routes arrive on the client-facing session, are stamped with both the standard community and the color extended community in a single pass, and are then advertised into VPN-IPv4 carrying both. No new route-map blocks or `network` statement changes are required. + +--- + +### Controller Changes + +All config changes are applied to `tunnel.tmpl`. Five additions are required. All blocks are conditioned on `and .Features.FlexAlgo .LinkColors` — when the `FlexAlgo` feature flag is unset, no flex-algo config is generated regardless of onchain link color state. + +#### 1. Interface admin-group tagging + +Inside the existing `{{- range .Device.Interfaces }}` block, after the `isis metric` / `isis network point-to-point` lines, add admin-group config for physical IS-IS links: + +``` +{{- if and .Ip.IsValid .IsPhysical .Metric .IsLink (not .IsSubInterfaceParent) (not .IsCYOA) (not .IsDIA) }} + traffic-engineering + {{- if .LinkColor }} + traffic-engineering administrative-group {{ $.Strings.ToUpper .LinkColor.Name }} + {{- else }} + no traffic-engineering administrative-group + {{- end }} +{{- end }} +``` + +`.LinkColor` is nil when `link.link_color` is `Pubkey::default()`. The `no traffic-engineering administrative-group` line ensures stale config is removed when a color is cleared. Interface-level admin-group tagging is conditioned on `.Features.FlexAlgo` alone — not on `.LinkColors` — since an interface may have a color assigned onchain while the feature flag is unset. + +#### 2. router traffic-engineering block + +Add after the `router isis 1` block, conditional on colors being defined and the feature flag being set: + +``` +{{- if and .Features.FlexAlgo .LinkColors }} +router traffic-engineering + router-id ipv4 {{ .Device.Vpn4vLoopbackIP }} + {{- range .LinkColors }} + administrative-group alias {{ $.Strings.ToUpper .Name }} group {{ .AdminGroupBit }} + {{- end }} + ! + flex-algo + {{- range .LinkColors }} + flex-algo {{ .FlexAlgoNumber }} {{ .Name }} + {{- if eq .Constraint "include-any" }} + administrative-group include any {{ .AdminGroupBit }} + {{- else }} + administrative-group exclude {{ .AdminGroupBit }} + {{- end }} + color {{ .EosColorValue }} + {{- end }} +{{- end }} +``` + +`.LinkColors` is the ordered list of `LinkColorInfo` accounts, sorted by `AdminGroupBit`. `.EosColorValue` is computed as `AdminGroupBit + 1`. The flex-algo name (e.g., `unicast-default`) is the color name stored in `LinkColorInfo`. + +#### 3. BGP next-hop resolution + +Inside the existing `address-family vpn-ipv4` block, replace any existing `next-hop resolution ribs` line with: + +``` + address-family vpn-ipv4 + {{- range .Vpnv4BgpPeers }} + {{- if ne .PeerIP.String $.Device.Vpn4vLoopbackIP.String }} + neighbor {{ .PeerIP }} activate + {{- end }} + {{- end }} + {{- range .UnknownBgpPeers }} + no neighbor {{ . }} + {{- end }} + {{- if and .Features.FlexAlgo .LinkColors }} + next-hop resolution ribs tunnel-rib colored system-colored-tunnel-rib system-connected + {{- end }} + ! +``` + +#### 4. IS-IS flex-algo advertisement and traffic-engineering + +Inside the existing `router isis 1` block, two additions are required, both conditional on colors being defined. + +Under `segment-routing mpls`, add a `flex-algo` advertisement line per color so the device participates in each constrained topology and advertises the corresponding flex-algo to its IS-IS neighbors: + +``` + segment-routing mpls + no shutdown + {{- range .LinkColors }} + flex-algo {{ .FlexAlgoNumber }} level-2 advertised + {{- end }} +``` + +After the `segment-routing mpls` block, add the `traffic-engineering` section that enables IS-IS TE on the device, which is required for admin-group advertisements to appear in the LSDB: + +``` +{{- if and .Features.FlexAlgo .LinkColors }} + traffic-engineering + no shutdown + is-type level-2 +{{- end }} +``` + +Without both of these, the device does not advertise its flex-algo node-SIDs and does not include admin-group attributes in its IS-IS LSP. Other devices cannot include it in the constrained SPF and cannot steer VPN traffic to it. + +#### 5. Loopback flex-algo node-segment + +Inside the existing `{{- range .Device.Interfaces }}` block, extend the existing `node-segment` line on the Vpn4vLoopback interface to also advertise a flex-algo node-SID per color: + +``` +{{- if and .IsVpnv4Loopback .NodeSegmentIdx }} + node-segment ipv4 index {{ .NodeSegmentIdx }} + {{- range $.LinkColors }} + {{- if .FlexAlgoNodeSegmentIdx }} + node-segment ipv4 index {{ .FlexAlgoNodeSegmentIdx }} flex-algo {{ .Name }} + {{- end }} + {{- end }} +{{- end }} +``` + +The flex-algo node-segment index follows the same pattern as the existing algo-0 `node_segment_idx`: it is allocated from the `SegmentRoutingIds` `ResourceExtension` account at interface activation time (in `processors/device/interface/activate.rs`) and stored on the `Interface` account onchain. A new `flex_algo_node_segment_idx: u16` field MUST be added to the `Interface` account and allocated alongside `node_segment_idx` when the interface's loopback type is `Vpnv4`. It is deallocated on `remove`, following the existing pattern. + +**Migration for existing interfaces:** Vpn4v loopback interfaces that were activated before this RFC will not have `flex_algo_node_segment_idx` allocated. A one-time `doublezero-admin` CLI migration command MUST be provided to iterate all existing `Interface` accounts with `loopback_type = Vpnv4`, allocate a `flex_algo_node_segment_idx` for each, and persist the updated account. This migration MUST be run before the `FlexAlgo` feature flag is enabled; without it, existing devices will not advertise flex-algo node-SIDs and will be unreachable via the constrained topology. + +Without a flex-algo node-SID on the loopback, remote devices cannot compute a valid constrained path to this device and VPN routes to it will not resolve via the colored tunnel RIB. + +All seven blocks are conditional on `.LinkColors` being non-empty, so devices with no colors defined produce identical config to today. + +--- + +### SDK Changes + +`LinkColorInfo` MUST be added to the Go, Python, and TypeScript SDKs. The `link` deserialization structs MUST include the new `link_color` pubkey field. Fixture files MUST be regenerated. + +--- + +### Tests + +#### Smart contract (integration tests) + +**LinkColorInfo lifecycle:** +- A foundation key MUST be able to create a `LinkColorInfo` account with a name; admin-group bit and flex-algo number MUST be auto-assigned starting at 0 and 128 respectively. +- Creating a second color MUST auto-assign bit 1 and flex-algo 129. +- A non-foundation key MUST NOT be able to create a `LinkColorInfo` account; the instruction MUST be rejected with an authorization error. +- All `LinkColorInfo` fields are immutable after creation; an `update` instruction MUST be rejected or be a no-op. +- A non-foundation key MUST NOT be able to update a `LinkColorInfo` account. +- `delete` MUST succeed when no links reference the color; the `LinkColorInfo` PDA MUST be removed onchain. +- `delete` MUST fail when one or more links still reference the color. +- After `clear`, all links previously assigned the color MUST have `link_color = Pubkey::default()`. +- After `clear` followed by `delete`, the `LinkColorInfo` PDA MUST be absent. +- Admin-group bits from deleted colors MUST NOT be reused by subsequently created colors. +- After `delete`, the controller MUST NOT generate removal commands for the deleted color's admin-group alias, flex-algo definition, or IS-IS TE config — device-side cleanup is deferred. + +**Link color assignment:** +- `link_color` MUST default to `Pubkey::default()` on a newly created link account and on existing accounts deserialized from pre-upgrade binary data. +- A foundation key MUST be able to set `link_color` to a valid `LinkColorInfo` pubkey on any link. +- A contributor key MUST NOT be able to set `link_color`; the instruction MUST be rejected with an authorization error. +- Setting `link_color` to `Pubkey::default()` from a non-default color MUST be accepted and persist correctly. +- Setting `link_color` to a pubkey that does not correspond to a valid `LinkColorInfo` account MUST be rejected. + +#### Controller (unit tests) + +- A link with `link_color = Pubkey::default()` MUST produce interface config with no `traffic-engineering administrative-group` line. +- A link with `link_color` referencing a `LinkColorInfo` with bit 0, name "unicast-default", and constraint `IncludeAny` MUST produce interface config with `traffic-engineering administrative-group UNICAST-DEFAULT`. +- Transitioning a link from a color to default MUST produce a `no traffic-engineering administrative-group` diff. +- Transitioning a link from one color to another MUST produce the correct remove/add diff. +- The `router traffic-engineering` block MUST include `color ` on each flex-algo definition. +- The BGP `next-hop resolution ribs tunnel-rib colored system-colored-tunnel-rib system-connected` config MUST be generated correctly. +- A per-tunnel inbound route-map MUST include `set extcommunity color 1` for unicast tunnels when the `FlexAlgo` flag is set and `LinkColors` is non-empty. +- A new `LinkColorInfo` account detected on reconciliation MUST cause the controller to push updated config to all devices. +- **Feature flag — unset:** With `LinkColorInfo` accounts defined, links tagged, and `FlexAlgo` feature flag unset, the controller MUST generate no `router traffic-engineering` block, no flex-algo IS-IS config, no `next-hop resolution ribs` line, and no `set extcommunity color` in any route-map. Device config MUST be identical to a network with no colors defined. +- **Feature flag — set:** Enabling the `FlexAlgo` flag with existing `LinkColorInfo` accounts and tagged links MUST cause the controller to generate the full flex-algo config block on the next reconciliation cycle. +- **Feature flag — interface tagging independent:** Interface-level `traffic-engineering administrative-group` config MUST be generated based on `Features.FlexAlgo` alone, regardless of whether `.LinkColors` is populated, ensuring tagged interfaces are correctly configured once the flag is set. + +#### SDK (unit tests) + +- `LinkColorInfo` account MUST serialize and deserialize correctly via Borsh for all fields. +- `LinkColorInfo` account MUST deserialize correctly from a binary fixture. +- `link_color` pubkey field MUST be included in `link get` and `link list` output in all three SDKs, showing the color name (resolved from `LinkColorInfo`) or "default". +- The `list` command MUST display the derived EOS color value (`admin_group_bit + 1`) in output. + +#### End-to-end (cEOS testcontainers) + +- **Color creation**: After a foundation key creates a `LinkColorInfo` for "unicast-default" (bit 0, flex-algo 128, constraint include-any), the controller MUST push `router traffic-engineering` config with `administrative-group alias UNICAST-DEFAULT group 0`, `flex-algo 128 unicast-default administrative-group include any 0 color 1`, and the BGP `next-hop resolution ribs` line to all devices. +- **Admin-group application**: After a foundation key sets `link_color` on a link to the unicast-default `LinkColorInfo` pubkey, `show traffic-engineering database` on the connected devices MUST reflect `UNICAST-DEFAULT` admin-group on the interface. Setting the color back to default MUST remove the admin-group. +- **Flex-algo topology**: With links tagged UNICAST-DEFAULT, `show isis flex-algo` on participating devices MUST show algo 128 including only UNICAST-DEFAULT links. Untagged links MUST be absent from the algo-128 LSDB view. +- **Colored tunnel RIB**: `show tunnel rib system-colored-tunnel-rib brief` MUST show (endpoint, color 1) entries for each participating device, resolving via unicast-default tunnels. +- **VPN unicast path selection**: A BGP VPN-IPv4 route carrying `Color:CO(00):1` MUST resolve its next-hop through the color-1 (unicast-default) tunnel in `system-colored-tunnel-rib`, traversing only UNICAST-DEFAULT tagged links. +- **Route-map color community**: `show bgp vpn-ipv4 detail` MUST show `Color:CO(00):1` on exported VPN-IPv4 routes from all tenant VRFs. +- **Multicast path isolation**: PIM RPF for a multicast source MUST continue to resolve via IS-IS algo 0 (all links, including both tagged and untagged) regardless of BGP next-hop resolution config. +- **Color clear**: After `link color clear --name unicast-default` removes the color from all links, the controller MUST generate `no traffic-engineering administrative-group UNICAST-DEFAULT` on all previously-tagged interfaces on the next reconciliation cycle. + +#### EOS Verification + +The following show commands serve as the basis for assertions in the e2e tests. All commands are hardware-verified on chi-dn-dzd5–dzd8 (EOS 4.31.2F). + +**Verify flex-algo participation and path selection:** +``` +show isis flex-algo +show isis flex-algo path +``` +MUST confirm algo 128 is advertised at Level-2, the `color` field is set, and the selected path includes only UNICAST-DEFAULT tagged links. + +**Verify colored tunnel RIB population:** +``` +show tunnel rib system-colored-tunnel-rib brief +show tunnel fib isis flex-algo +``` +MUST confirm (endpoint, color 1) entries are present for each participating device, resolving via unicast-default tunnels. + +**Verify node-segments include flex-algo SIDs:** +``` +show isis segment-routing prefix-segments +``` +MUST confirm each participating device advertises both an algo-0 index and a flex-algo index for each defined color. + +**Verify BGP next-hop resolution binding is active:** +``` +show bgp instance +``` +MUST confirm `address-family IPv4 MplsVpn` shows `Resolution RIBs: tunnel-rib colored system-colored-tunnel-rib, system-connected`. + +**Verify VPN route color community and resolution:** +``` +show bgp vpn-ipv4 detail +show ip route vrf bgp +``` +MUST confirm BGP VPN-IPv4 routes carry `Color:CO(00):1` and resolve next-hops through `system-colored-tunnel-rib` tunnels. + +**Verify TE database admin-groups:** +``` +show traffic-engineering database +show traffic-engineering interfaces +``` +MUST confirm admin-group membership is visible only on interfaces with a non-default link color. + +**Verify multicast RPF uses algo-0 (including colored links):** +``` +show ip mroute +``` +MUST confirm PIM RPF resolves via the IS-IS unicast RIB (algo 0). The incoming interface for a multicast source reachable via a colored link MUST be the colored interface, unchanged by BGP next-hop resolution config. + +--- + +## Impact + +### Codebase + +- **serviceability** — new `LinkColorInfo` PDA (foundation-managed, one per color); new `link_color: Pubkey` field on `Link`; new `link_color: Option` field on `LinkUpdateArgs` with foundation-only write restriction; new `flex_algo_node_segment_idx: u16` field on `Interface`; `FlexAlgo = 2` added to `FeatureFlag` enum. +- **controller** — reads `GlobalState.feature_flags` to gate all flex-algo config on the `FlexAlgo` flag; reads `link.link_color`, resolves `LinkColorInfo` PDAs, generates IS-IS TE admin-group config on interfaces, flex-algo definitions with `color` field, `system-colored-tunnel-rib` BGP resolution profile, and adds `set extcommunity color` to the existing per-tunnel inbound route-maps (`RM-USER-{{ .Id }}-IN`). +- **CLI** — full color lifecycle commands (`create`, `update`, `delete`, `clear`, `list`); `link update` gains `--link-color`; `link get` / `link list` display the field including derived EOS color value. +- **SDKs** — `LinkColorInfo` added to all three language SDKs; `link_color` field added to link deserialization structs; `FlexAlgo` flag added to feature flag constants. + +### Operational + +- DZF MUST create a `LinkColorInfo` account and assign `link_color` on links before the controller applies TE admin-groups. Until a color is created and assigned, links behave as today. +- Adding a new color MUST NOT require a code change or deploy — DZF creates the `LinkColorInfo` account via the CLI and the controller picks it up on the next reconciliation cycle. +- `link_color` appends to the serialized layout and defaults to `Pubkey::default()` on existing accounts. No migration is required. +- The transition from no-color to color-1 on all tenant VRFs is a one-time controller config push. The template section order enforces the correct sequencing within a single reconciliation cycle: the `router traffic-engineering` block and `address-family vpn-ipv4 next-hop resolution` config appear before the `route-map RM-USER-*-IN` blocks in `tunnel.tmpl`, so EOS applies them top-to-bottom in the correct order. Applying the route-map before the RIB is configured would cause VPN routes to go unresolved. + +### Testing + +| Layer | Tests | +|---|---| +| Smart contract | `LinkColorInfo` create/update/delete lifecycle; foundation-only authorization; auto-assignment of bit and flex-algo number; `link_color` assignment and clearing; delete blocked when links assigned; `clear` removes color from all links | +| Controller (unit) | Interface config with and without color; remove/add diff on color change; `router traffic-engineering` block with `color` field; `system-colored-tunnel-rib` BGP resolution config; per-VRF route-map generation; new color triggers config push to all devices | +| SDK (unit) | `LinkColorInfo` Borsh round-trip; `link_color` field in `link get` / `link list` output across Go, Python, and TypeScript; EOS color value displayed correctly | +| End-to-end (cEOS) | Color create → controller config push verified; admin-group applied and removed on interface; flex-algo 128 topology with `color 1` excludes colored link; `system-colored-tunnel-rib` populated; VPN unicast resolves via color-1 tunnel; BGP VPN-IPv4 routes carry correct color community; multicast RPF unchanged on untagged links; color clear removes admin-groups from interfaces; onchain delete removes PDA | + +--- + +## Security Considerations + +- `link_color` MUST only be writable by foundation keys. A contributor MUST NOT be able to tag their own link with a color to influence path steering. The check mirrors the existing pattern used for `link.status` foundation-override. +- `LinkColorInfo` accounts MUST only be created, updated, or deleted by foundation keys. +- If a foundation key is compromised, an attacker could reclassify links or create new color definitions, causing traffic to be steered onto unintended paths. This is the same threat surface as other foundation-key-controlled fields. No new mitigations are introduced. + +--- + +## Backward Compatibility + +- The `link_color` field MUST default to `Pubkey::default()` (no color assigned) on existing accounts. No migration is required. +- Devices that do not receive updated config from the controller MUST continue to forward using IS-IS algo 0 only. The flex-algo topology is distributed — a device that does not participate is simply not included in the constrained SPF. +- The controller MUST push `system-colored-tunnel-rib` config and flex-algo definitions before activating per-VRF route-maps. Activating route-maps first causes VPN routes to carry a color community with no matching tunnel RIB entry, leaving them unresolved. +- `tunnel-rib` config generated by the controller uses a single BGP next-hop resolution profile applied globally. Per-tenant resolution profile overrides are not supported at this stage. + +--- + +## Observability + +Introducing multiple forwarding topologies over the same physical links has implications for the network visualization tools in the lake repository. Today, lake displays a single IS-IS topology where every link is treated equally. With flex-algo, a link's effective participation depends on its color and the traffic type being considered — an untagged link is present in the algo-0 view but absent from the algo-128 (unicast-default) view used by VPN unicast. + +Areas that SHOULD evolve: + +- **Topology view filtering** — the topology map SHOULD allow operators to switch between views: all links (algo 0), unicast topology (algo 128), or a per-service overlay. A link's color SHOULD be visually distinct and its inclusion or exclusion from each topology SHOULD be clear. +- **Link color display** — link color and admin-group membership SHOULD be surfaced on the topology map alongside existing link attributes (latency, capacity). This gives operators immediate visibility into how a link is classified without needing to query the CLI. +- **Service-aware path visualization** — when tracing a path between two nodes, the tool SHOULD reflect which topology that traffic type actually uses. A unicast path between two nodes may differ from the multicast path over the same physical graph. +- **Tenant / VRF context** — VPN unicast traffic is resolved per VRF. A future multi-VRF deployment with per-tenant colors would require topology views to be scoped to a tenant, showing the paths available within that VRF's forwarding context. + +--- + +## Open Questions + +- **Color naming convention**: The RFC defines `unicast-default` and `shelby` as the initial colors. Should DZF adopt a consistent naming convention for future colors? Options include: + - **Functionality** (`unicast-default`, `low-latency`) — self-documenting from an operational perspective, but ties the name to a specific use-case that may evolve. + - **Product/tenant names** (`shelby`, `shreds`) — scoped to a customer or service, which may be appropriate if colors end up being per-tenant rather than network-wide. + The name is stored onchain in `LinkColorInfo` and appears in EOS config (converted to uppercase as the admin-group alias), so it SHOULD be stable once assigned. From 0b1b5a6ba062bd9172bfd57368b97eec36da5d9c Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 19 Mar 2026 20:50:34 -0500 Subject: [PATCH 02/49] =?UTF-8?q?rfcs:=20rfc-18=20major=20revision=20?= =?UTF-8?q?=E2=80=94=20per-tenant=20colors,=20controller=20config,=20multi?= =?UTF-8?q?-color,=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace onchain feature flag with controller features.yaml config file - Add LinkColorInfo account with AdminGroupBits ResourceExtension for persistent bit allocation; bits never reused after deletion - Change link_color: Pubkey to link_colors: Vec (cap 8) - Add include_topology_colors: Vec on Tenant for per-tenant color assignment; defaults to UNICAST-DEFAULT (color 1) - Redesign interface admin-group cleanup: overwrite remaining colors on deletion rather than targeted named no command - Add full revert: enabled: false removes all flex-algo config - Pin UNICAST-DEFAULT as protocol invariant (bit 0, first color created) - Add controller startup check blocking enabled: true if any Vpn4v loopback has unset flex_algo_node_segment_idx - Clarify clear sweep atomicity and idempotency - Address all PR review comments (nikw9944, vihu, elitegreg) Co-Authored-By: Claude Sonnet 4.6 --- rfcs/rfc18-link-classification-flex-algo.md | 307 +++++++++++++------- 1 file changed, 194 insertions(+), 113 deletions(-) diff --git a/rfcs/rfc18-link-classification-flex-algo.md b/rfcs/rfc18-link-classification-flex-algo.md index f6bf2d58e9..45c989399e 100644 --- a/rfcs/rfc18-link-classification-flex-algo.md +++ b/rfcs/rfc18-link-classification-flex-algo.md @@ -9,15 +9,14 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S DoubleZero contributors operate links with different physical characteristics — low latency, high bandwidth, or both. Today all traffic uses the same IS-IS topology, so every service follows the same paths regardless of what those paths are optimized for. This RFC introduces a link classification model that allows DZF to assign named color labels to links onchain and use IS-IS Flexible Algorithm (flex-algo) to compute separate constraint-based forwarding topologies per color. Different traffic classes — VPN unicast and IP multicast — can then use different topologies. **Deliverables:** -- `LinkColorInfo` onchain account — DZF creates this to define a color, with auto-assigned admin-group bit, flex-algo number, and derived EOS color value -- `link_color` field on the serviceability link account — references the assigned color -- `FlexAlgo` feature flag (bit 2) in the existing `GlobalState.feature_flags` bitmask — gates all flex-algo device config; must be explicitly enabled by DZF before the controller pushes any flex-algo configuration to devices -- Controller logic — translates colors into IS-IS TE admin-groups on interfaces, generates flex-algo topology definitions, configures `system-colored-tunnel-rib` as the BGP next-hop resolution source, and applies BGP color extended community route-maps per VRF; all conditioned on the `FlexAlgo` feature flag +- `LinkColorInfo` onchain account — DZF creates this to define a color, with auto-assigned admin-group bit (from the `AdminGroupBits` `ResourceExtension`), flex-algo number, and derived EOS color value +- `link_colors: Vec` field on the serviceability link account — references assigned colors; capped at 8 entries; only the first entry is used by the controller in this RFC +- Controller feature config file (`features.yaml`) — loaded at startup; gates flex-algo topology config, link admin-group tagging, and BGP color community stamping independently; replaces any onchain feature flag for this capability +- Controller logic — translates colors into IS-IS TE admin-groups on interfaces, generates flex-algo topology definitions, configures `system-colored-tunnel-rib` as the BGP next-hop resolution source, and applies BGP color extended community route-maps per tunnel; all conditioned on the controller config **Scope:** - Delivers traffic-class-level segregation: multicast vs. VPN unicast at the network level -- All unicast tenants share a single constrained topology today — the architecture is forward-compatible with per-tenant path differentiation without rework -- Per-tenant steering (directing one tenant to a different constrained topology) requires adding a `topology_color` field to the `Tenant` account — deferred to a future RFC that builds on the link color model defined here +- Per-tenant unicast path differentiation via `include_topology_colors: Vec` on the `Tenant` account — all unicast tenants receive color 1 (UNICAST-DEFAULT) by default; `include_topology_colors` overrides this to assign specific color values and steer the tenant onto a designated topology --- @@ -35,15 +34,15 @@ IS-IS Flexible Algorithm provides the routing mechanism: each flex-algo defines ## New Terminology -- **Link color** — A DZF-defined label assigned to a link that maps to an IS-IS TE admin-group. Determines which flex-algo topologies include or exclude the link. - **Admin-group** — An IS-IS TE attribute assigned to a physical interface that flex-algo algorithms use as include/exclude constraints. Also called "affinity" in some implementations. Arista EOS supports bits 0–127. -- **Flex-algo** — IS-IS Flexible Algorithm (RFC 9350). Each algorithm defines a constrained topology (metric type + admin-group include/exclude rules) and computes an independent SPF. Nodes with the same flex-algo compute consistent paths across the topology. Arista EOS supports flex-algo numbers 128–255. -- **EOS color value** — An integer assigned to a flex-algo definition in EOS (`color ` under `flex-algo`). Causes EOS to install that algorithm's computed tunnels in `system-colored-tunnel-rib` keyed by (endpoint, color). Derived as `admin_group_bit + 1`; not stored separately. -- **system-colored-tunnel-rib** — An EOS system RIB auto-populated when flex-algo definitions carry a `color` field. Keyed by (endpoint, color). Used by BGP next-hop resolution to steer VPN routes onto constrained topologies based on the BGP color extended community carried on the route. - **BGP color extended community** — A BGP extended community (`Color:CO(00):`) set on VPN-IPv4 routes inbound on the client-facing BGP session. The color value matches the EOS flex-algo color, enabling per-route algorithm selection at devices receiving the route via VPN-IPv4. -- **UNICAST-DEFAULT** — The first color DZF creates. Auto-assigned admin-group bit 0, flex-algo 128, EOS color value 1. Applied to all links eligible for the default unicast topology. Flex-algo 128 uses `include-any UNICAST-DEFAULT`, so only explicitly tagged links participate in the unicast topology. Untagged links are excluded from unicast but remain available to multicast via IS-IS algo 0. +- **Controller feature config** — A YAML file loaded by the controller at startup that gates flex-algo topology config, link tagging, and color community stamping independently. Controls the staged rollout of flex-algo to the network without requiring onchain transactions. +- **EOS color value** — An integer assigned to a flex-algo definition in EOS (`color ` under `flex-algo`). Causes EOS to install that algorithm's computed tunnels in `system-colored-tunnel-rib` keyed by (endpoint, color). Derived as `admin_group_bit + 1`; not stored separately. +- **Flex-algo** — IS-IS Flexible Algorithm (RFC 9350). Each algorithm defines a constrained topology (metric type + admin-group include/exclude rules) and computes an independent SPF. Nodes with the same flex-algo compute consistent paths across the topology. Arista EOS supports flex-algo numbers 128–255. +- **Link color** — A DZF-defined label assigned to a link that maps to an IS-IS TE admin-group. Determines which flex-algo topologies include or exclude the link. - **Link color constraint** — Each `LinkColorInfo` defines either an `IncludeAny` or `Exclude` constraint. `IncludeAny`: only links explicitly tagged with this color participate in the topology. `Exclude`: all links except those tagged with this color participate. UNICAST-DEFAULT uses `IncludeAny`. -- **FlexAlgo feature flag** — Bit 2 in `GlobalState.feature_flags`. When unset, the controller generates no flex-algo config regardless of how many `LinkColorInfo` accounts exist or how many links are tagged. Allows DZF to prepare onchain state independently of device rollout timing. +- **system-colored-tunnel-rib** — An EOS system RIB auto-populated when flex-algo definitions carry a `color` field. Keyed by (endpoint, color). Used by BGP next-hop resolution to steer VPN routes onto constrained topologies based on the BGP color extended community carried on the route. +- **UNICAST-DEFAULT** — The reserved default color. MUST be the first color created by DZF and MUST be assigned admin-group bit 0, flex-algo 128, and EOS color value 1. These values are protocol invariants — the controller resolves the default tenant color by looking up the `LinkColorInfo` where `admin_group_bit == 0`, not by creation order. Applied to all links eligible for the default unicast topology. Flex-algo 128 uses `include-any UNICAST-DEFAULT`, so only explicitly tagged links participate in the unicast topology. Untagged links are excluded from unicast but remain available to multicast via IS-IS algo 0. --- @@ -55,7 +54,7 @@ IS-IS Flexible Algorithm provides the routing mechanism: each flex-algo defines | Multicast uses all links (algo 0) | ✅ | Natural PIM RPF behavior; includes both tagged and untagged links; no config required | | Multiple links with the same color | ✅ | All tagged links participate together in the constrained topology | | New links excluded from unicast by default | ✅ | `include-any` strictly excludes untagged links — verified in lab. New links must be explicitly tagged before they carry unicast traffic | -| Per-tenant unicast path differentiation | ⚠️ | Architecture proven in lab (BGP color extended communities + `system-colored-tunnel-rib`). Today all unicast tenants share one color. Per-tenant steering requires `topology_color` on `Tenant` account — deferred to a future RFC | +| Per-tenant unicast path differentiation | ✅ | Architecture proven in lab (BGP color extended communities + `system-colored-tunnel-rib`). All unicast tenants receive color 1 (UNICAST-DEFAULT) by default; `include_topology_colors` overrides this with specific colors to steer onto a designated topology | | Exclude a link from multicast | ❌ | PIM RPF uses IS-IS algo 0 unconditionally. No EOS mechanism can redirect multicast away from specific links within the current architecture | | Automated link selection by bandwidth or type | ❌ | Link tagging is manual DZF policy at this stage. `link.bandwidth` and `link.link_type` exist onchain and can drive automated selection in a future RFC | @@ -79,12 +78,11 @@ Three mechanisms are deferred. Each addresses a distinct escalation in steering | Mechanism | Granularity | Solves | Complexity | Trigger to adopt | |---|---|---|---|---| -| Per-tenant flex-algo color | Per-tenant VRF | Different constrained topology per tenant | Low — add `topology_color` to `Tenant` account; controller generates per-VRF route-map with matching color | First tenant requiring path isolation from the default unicast topology | | CBF with non-default VRFs | Per-tenant VRF, per-DSCP | Different constrained topology per tenant with DSCP-based sub-steering | Medium — TCAM profile change; builds on flex-algo colors defined here | First tenant requiring DSCP-level path differentiation within a VRF | | SR-TE | Per-prefix or per-flow | Explicit path control with segment lists; per-prefix or per-DSCP steering independent of IGP topology | High — controller must compute or define explicit segment lists per policy, and set BGP Color Extended Community on routes per-tenant | Per-prefix SLA requirements, or when per-tenant flex-algo color is insufficient | | RSVP-TE | Per-LSP (P2P unicast) or per-tree (P2MP multicast) | Hard bandwidth reservation with admission control | High — RSVP-TE on all path devices, IS-IS TE bandwidth advertisement, controller logic to provision per-tenant tunnel interfaces | SLA-backed bandwidth guarantees where admission control is required, not just path preference | -The lowest-cost next step for per-tenant differentiation is adding `topology_color: Option` to the `Tenant` account. The controller reads it, resolves the `LinkColorInfo` PDA, and generates a VRF-specific route-map with the corresponding color value. No new network infrastructure is required — it builds directly on the `system-colored-tunnel-rib` mechanism defined here. +An `exclude_topology_colors: Vec` field on `Tenant` is a natural extension of the `include_topology_colors` model defined here — it would allow a tenant to explicitly avoid certain topologies. This is deferred; no network infrastructure changes are required to add it when needed. --- @@ -94,16 +92,18 @@ The lowest-cost next step for per-tenant differentiation is adding `topology_col #### LinkColorInfo account -DZF creates a `LinkColorInfo` PDA per color. It stores the color's name and auto-assigned routing parameters. The program MUST auto-assign the next available admin-group bit (starting at 0) and the corresponding flex-algo number and EOS color value using the formula: +DZF creates a `LinkColorInfo` PDA per color. It stores the color's name and auto-assigned routing parameters. The program MUST auto-assign the lowest available admin-group bit from the `AdminGroupBits` `ResourceExtension` account, and derive the corresponding flex-algo number and EOS color value using the formula: ``` -admin_group_bit = next available bit in 0–127 +admin_group_bit = next available bit from AdminGroupBits ResourceExtension (0–127) flex_algo_number = 128 + admin_group_bit eos_color_value = admin_group_bit + 1 (derived, not stored) ``` This formula ensures the admin-group bit, flex-algo number, and EOS color value are always in the EOS-supported ranges (bits 0–127, algos 128–255, color 1–4294967295) and are derived consistently from each other. The EOS color value is not stored onchain — it is computed by the controller wherever needed. +The `AdminGroupBits` `ResourceExtension` is a persistent bitmap on `GlobalState` that tracks allocated admin-group bits across the lifetime of the program, including bits from deleted colors. This ensures bits are never reused after deletion — reusing a bit before all devices have had their config updated would cause those devices to apply the new color's constraints to interfaces still carrying the old bit's admin-group. The bitmap survives PDA deletion, which a PDA-scan approach cannot guarantee. + ```rust #[derive(BorshSerialize, BorshDeserialize, Debug)] pub enum LinkColorConstraint { @@ -114,7 +114,7 @@ pub enum LinkColorConstraint { #[derive(BorshSerialize, BorshDeserialize, Debug)] pub struct LinkColorInfo { pub name: String, // e.g. "unicast-default" - pub admin_group_bit: u8, // auto-assigned, 0–127 + pub admin_group_bit: u8, // auto-assigned from AdminGroupBits ResourceExtension, 0–127 pub flex_algo_number: u8, // auto-assigned, 128–255; always 128 + admin_group_bit pub constraint: LinkColorConstraint, // IncludeAny or Exclude } @@ -126,19 +126,19 @@ Name length MUST NOT exceed 32 bytes, enforced by the program on `create`. This The program MUST validate `admin_group_bit <= 127` on `create` and MUST return an explicit error if all 128 slots are exhausted. This is a hard constraint: EOS supports bits 0–127 only, and `128 + 127 = 255` is the maximum representable value in `flex_algo_number: u8`. -Admin-group bits from deleted colors MUST NOT be reused. Color deletion is not supported in this RFC, so this constraint applies to any future deletion implementation: reusing a bit before all devices have had their config updated would cause those devices to apply the new color's constraints to interfaces still carrying the old bit's admin-group. At current scale (128 available slots), exhaustion is not a practical concern. +#### link_colors field on Link -#### link_color field on Link +A `link_colors: Vec` field is added to the serviceability `Link` account, capped at 8 entries. Each entry holds the pubkey of a `LinkColorInfo` PDA. An empty vector indicates no color is assigned. The field appends to the end of the serialized layout, defaulting to an empty vector on existing accounts. -A `link_color` field is added to the serviceability `Link` account. It holds the pubkey of the `LinkColorInfo` PDA for the assigned color, or `Pubkey::default()` if no color is assigned. The field appends to the end of the serialized layout, defaulting to `Pubkey::default()` on existing accounts. +The cap of 8 exists to keep the `Link` account size deterministic on-chain. Only the first entry (`link_colors[0]`) is used by the controller in this RFC — multiple entries are reserved for future multi-color-per-link support (e.g., a link participating in both UNICAST-DEFAULT and SHELBY topologies simultaneously, as validated in lab testing). -`link_color` MUST only be set by keys in the DZF foundation allowlist. Contributors MUST NOT set this field. Link tagging is a DZF policy decision — there is no automated selection based on `link.bandwidth` or `link.link_type` at this stage. +`link_colors` MUST only be set by keys in the DZF foundation allowlist. Contributors MUST NOT set this field. Link tagging is a DZF policy decision — there is no automated selection based on `link.bandwidth` or `link.link_type` at this stage. ```rust // Foundation-only fields if globalstate.foundation_allowlist.contains(payer_account.key) { - if let Some(link_color) = value.link_color { - link.link_color = link_color; + if let Some(link_colors) = value.link_colors { + link.link_colors = link_colors; } } ``` @@ -155,13 +155,11 @@ doublezero link color clear --name doublezero link color list ``` -- `create` — creates a `LinkColorInfo` PDA; auto-assigns the next available admin-group bit and flex-algo number; stores the specified constraint (`include-any` or `exclude`). MUST fail if the name already exists. If the `FlexAlgo` feature flag is set, the controller will include this color in the `router traffic-engineering` block, IS-IS TE advertisement, and BGP next-hop resolution config on the next reconciliation cycle. No immediate device impact if the `FlexAlgo` feature flag is not set. +- `create` — creates a `LinkColorInfo` PDA; allocates the lowest available admin-group bit from the `AdminGroupBits` `ResourceExtension`; derives and stores flex-algo number; stores the specified constraint (`include-any` or `exclude`). MUST fail if the name already exists. The first color created MUST be named `unicast-default` and will be allocated bit 0 — this is a protocol invariant and the program MUST enforce it by rejecting any `create` instruction where the `AdminGroupBits` bitmap is empty and the name is not `unicast-default`. Device impact is controlled entirely by the controller feature config — no device config is generated until `flex_algo.enabled: true` is set in the config file. - `update` — reserved for future use; all fields are immutable after creation. No device config change. -- `delete` — removes the `LinkColorInfo` PDA onchain. MUST fail if any link still references this color (use `clear` first). **Device-side cleanup is deferred** — the controller does not generate `no` commands to remove the admin-group alias, flex-algo definition, IS-IS TE advertisement, or BGP next-hop resolution config from devices when a color is deleted. See below. -- `clear` — removes this color from all links currently assigned to it, setting their `link_color` to `Pubkey::default()`. This is a multi-transaction sweep — one `LinkUpdateArgs` instruction is submitted per assigned link; it is not atomic. On the next reconciliation cycle, the controller generates `no traffic-engineering administrative-group ` on all previously-colored interfaces. -- `list` — fetches all `LinkColorInfo` accounts and all `Link` accounts and groups links by color: - -**Device-side cleanup on deletion is deferred.** The `delete` instruction removes the `LinkColorInfo` PDA onchain. The controller does not generate removal commands (`no administrative-group alias`, `no flex-algo`, etc.) when a color is deleted — device config is not cleaned up automatically. Safe device-side deletion would require the controller to surgically remove the admin-group alias, flex-algo definition, IS-IS TE advertisement, loopback node-segment, and BGP next-hop resolution config from all devices without disrupting surviving colors. EOS behavior complicates this: `no traffic-engineering administrative-group ` removes **all** admin-groups from the interface regardless of which name is specified — not just the named one. An interface that should retain a second color after deletion would need the surviving color re-applied atomically in the same reconciliation pass. The correct sequencing across a distributed reconciliation cycle has not been validated. Device cleanup on deletion is therefore deferred to a future enhancement. Colors SHOULD be treated as long-lived; the 128-slot limit is not a practical constraint at current scale. +- `delete` — removes the `LinkColorInfo` PDA onchain. MUST fail if any link still references this color (use `clear` first). On the next reconciliation cycle, the controller removes the deleted color's admin-group alias and flex-algo definition from all devices. Admin-group bits from deleted colors MUST NOT be reused — the `AdminGroupBits` `ResourceExtension` bitmap persists allocated bits permanently. +- `clear` — removes this color from all links currently assigned to it, setting `link_colors` to an empty vector on each. This is a multi-transaction sweep — one `LinkUpdateArgs` instruction is submitted per assigned link; it is not atomic. If the sweep fails partway through, the operator MUST re-run `clear`; the operation is idempotent and will only submit instructions for links that still reference the color. The `delete` guard (which rejects if any link still references the color) is the safety net — partial completion is safe because a re-run will clear the remaining references before deletion is attempted. On the next reconciliation cycle, the controller re-applies only the remaining colors on each affected interface — if other colors remain, `traffic-engineering administrative-group ` is applied; if no colors remain, `no traffic-engineering administrative-group` is applied. +- `list` — fetches all `LinkColorInfo` accounts and all `Link` accounts and groups links by color. SHOULD emit a warning if any color has fewer links tagged than the minimum required for a connected topology. ``` NAME CONSTRAINT FLEX-ALGO ADMIN-GROUP BIT EOS COLOR LINKS @@ -177,39 +175,91 @@ doublezero link update --pubkey --link-color default doublezero link update --code --link-color ``` -- `--link-color ` MUST resolve the color name to the corresponding `LinkColorInfo` PDA pubkey before submitting the instruction — the onchain field stores a pubkey, not a name. -- `--link-color default` sets `link_color` to `Pubkey::default()`, removing any color assignment. -- `doublezero link get` and `doublezero link list` MUST include `link_color` in their output, showing the resolved color name (or "default"). +- `--link-color ` MUST resolve the color name to the corresponding `LinkColorInfo` PDA pubkey before submitting the instruction — the onchain field stores pubkeys, not names. Sets `link_colors[0]`. +- `--link-color default` sets `link_colors` to an empty vector, removing any color assignment. +- `doublezero link get` and `doublezero link list` MUST include `link_colors` in their output, showing the resolved color names (or "default"). ---- - -### FlexAlgo Feature Flag +#### Tenant topology color assignment -The `FeatureFlag` enum in `smartcontract/programs/doublezero-serviceability/src/state/feature_flags.rs` already provides a network-wide bitmask mechanism in `GlobalState`. This RFC adds bit 2: +An `include_topology_colors: Vec` field is added to the serviceability `Tenant` account. Each entry holds the pubkey of a `LinkColorInfo` PDA. All unicast tenants receive color 1 (UNICAST-DEFAULT) by default; setting `include_topology_colors` overrides this to assign specific colors based on business requirements. The field appends to the end of the serialized layout, defaulting to an empty vector on existing accounts. ```rust -pub enum FeatureFlag { - OnChainAllocation = 0, - RequirePermissionAccounts = 1, - FlexAlgo = 2, // new +#[derive(BorshSerialize, BorshDeserialize, Debug)] +pub struct Tenant { + // ... existing fields ... + pub include_topology_colors: Vec, // appended; defaults to [] } ``` -Only foundation keys can set this flag via the existing `SetFeatureFlags` instruction. The controller reads `GlobalState.feature_flags` from `ProgramData` on each reconciliation cycle and exposes it to the template as `.Features.FlexAlgo`. +`include_topology_colors` MUST only be set by foundation keys. This is a routing policy decision — contributors MUST NOT be able to steer their own traffic onto a different topology by modifying this field. + +When a tenant has one entry in `include_topology_colors`, the controller resolves the `LinkColorInfo` PDA and stamps its EOS color value on inbound routes for that tenant. When a tenant has multiple entries, the controller stamps all corresponding color values — EOS then selects the best available colored tunnel by IGP metric (lowest metric wins; highest color number breaks ties). This enables a fallback chain: if the preferred topology's tunnel becomes unavailable, EOS automatically falls back to the next-best color on the same prefix without the route going unresolved. This behavior has been verified in lab testing. + +**CLI:** + +``` +doublezero tenant update --code --include-topology-colors [,] +doublezero tenant update --code --include-topology-colors default +``` + +- `--include-topology-colors [,]` resolves each color name to the corresponding `LinkColorInfo` PDA pubkey before submitting the instruction. +- `--include-topology-colors default` sets `include_topology_colors` to an empty vector, reverting the tenant to the default color 1 (UNICAST-DEFAULT). +- `doublezero tenant get` and `doublezero tenant list` MUST display `include_topology_colors` showing resolved color names (or "default"). + +--- + +### Controller Feature Configuration + +Flex-algo rollout is controlled by a YAML configuration file loaded by the controller at startup via the `-features-config` flag. This separates three distinct concerns that need to be rolled out independently: + +1. **Topology config** — flex-algo definitions, IS-IS TE, and BGP next-hop resolution config pushed to all devices +2. **Link admin-group tagging** — applying admin-group attributes to specific interfaces +3. **Color community stamping** — stamping BGP color extended communities on inbound tenant routes + +Config changes require a controller restart. This is intentional — restart triggers an immediate reconciliation cycle that applies or reverts the updated config. + +```yaml +features: + flex_algo: + enabled: true # pushes topology config to all devices; false reverts all flex-algo device config + link_tagging: + exclude: + links: + - # never apply admin-group on this link, overrides onchain assignment + community_stamping: + all: false # if true, stamps all tenants on all devices + tenants: + - # stamp this tenant on all devices + devices: + - # stamp all tenants on this device + exclude: + devices: + - # never stamp on this device; takes precedence over all positive rules +``` **Rollout sequence:** -The `FlexAlgo` feature flag decouples onchain state preparation from device deployment. DZF can create colors and tag links before any device receives flex-algo config, then enable the flag when the network is ready. +The config file decouples onchain state preparation from device deployment. DZF can create colors and tag links before any device receives flex-algo config, then enable features progressively: + +1. DZF creates `LinkColorInfo` accounts and assigns `link_colors` on links — no device impact while `enabled: false` +2. Set `enabled: true` and restart the controller — topology config is pushed to all devices on the next reconciliation cycle. No admin-group tagging or community stamping yet +3. Verify all devices show correct flex-algo state (`show isis flex-algo`, `show tunnel rib system-colored-tunnel-rib brief`) +4. Add specific links to the tagging config or leave the exclude list empty to tag all onchain-assigned links — restart controller +5. Add tenants or devices to `community_stamping` — restart controller. Stamping can be rolled out per-tenant or per-device to control which traffic begins using constrained topologies + +**Precedence for community stamping:** a device is stamped if `all: true`, OR its pubkey is in `devices`, OR the tenant's pubkey is in `tenants` — unless the device's pubkey is in `exclude.devices`, which overrides all positive rules. + +**Asymmetric routing:** if community stamping is enabled on some devices but not others, routes entering the network at unstamped devices will carry no color community and resolve via `system-connected` fallback. This is expected behaviour during a phased rollout, not an error condition. -1. DZF creates `LinkColorInfo` accounts and tags links with `link_color` — no device impact while the flag is unset -2. When ready to deploy to the network, DZF calls `SetFeatureFlags` with `FlexAlgo = true` -3. On the next reconciliation cycle, the controller generates and pushes all flex-algo config to all devices simultaneously +**Revert behaviour:** when `enabled` is set to `false` and the controller is restarted, the controller generates the full set of `no` commands to remove all flex-algo config from all devices on the next reconciliation cycle: `no router traffic-engineering`, `no flex-algo` definitions, `no next-hop resolution ribs`, and removal of `set extcommunity color` from all route-maps. -**All controller template blocks introduced by this RFC are conditioned on `and .Features.FlexAlgo .LinkColors`.** When the flag is unset, devices receive identical config to today regardless of onchain link color state. +**Single controller:** today there is a single controller instance; the config file approach is straightforward. Multiple controller instances would require config consistency across instances. This is deferred to a future RFC addressing decentralised controller architecture. + +--- ### IS-IS Flex-Algo Topology -Each link color maps to an IS-IS TE admin-group bit via the `LinkColorInfo` account. The controller MUST read `link.link_color`, resolve the `LinkColorInfo` PDA, and apply the corresponding admin-group to the physical interface. +Each link color maps to an IS-IS TE admin-group bit via the `LinkColorInfo` account. The controller MUST read `link.link_colors[0]`, resolve the `LinkColorInfo` PDA, and apply the corresponding admin-group to the physical interface — unless the link's pubkey is in `link_tagging.exclude.links`. | Link color | Constraint | Admin-group bit | Flex-algo number | EOS color value | Topology | |---|---|---|---|---|---| @@ -230,30 +280,30 @@ router traffic-engineering Flex-algo 128 ("unicast-default") computes an IS-IS SPF over only those links tagged `UNICAST-DEFAULT`. The `color 1` field causes EOS to install these tunnels in `system-colored-tunnel-rib` keyed by (endpoint, 1). Devices that participate in flex-algo 128 advertise both an algo-0 node-segment and an algo-128 node-segment via their loopback. -**Operational implication:** Every link intended to carry unicast traffic MUST be explicitly tagged `UNICAST-DEFAULT`. Links added to the network are excluded from the unicast topology by default until DZF assigns the color. +**Operational implication:** Every link intended to carry unicast traffic MUST be explicitly tagged `UNICAST-DEFAULT`. Links added to the network are excluded from the unicast topology by default until DZF assigns the color. The `link color list` command SHOULD warn if a color's topology appears disconnected based on the set of tagged links. #### Universal participation requirement -Flex-algo MUST be enabled on every device in the network, not only on devices that have colored links. A device that does not participate in a flex-algo does not advertise a node-SID for that algorithm, so other devices cannot include it in the constrained SPF and cannot steer VPN traffic to it via the constrained topology. VPN routes to a non-participating device will not resolve via the colored tunnel RIB and will fall back to the next resolution source. The controller MUST therefore push the flex-algo definitions and BGP next-hop resolution config to all devices unconditionally. Admin-group tagging on interfaces MUST only be applied to links with a non-default color. +Flex-algo MUST be enabled on every device in the network, not only on devices that have colored links. A device that does not participate in a flex-algo does not advertise a node-SID for that algorithm, so other devices cannot include it in the constrained SPF and cannot steer VPN traffic to it via the constrained topology. VPN routes to a non-participating device will not resolve via the colored tunnel RIB and will fall back to the next resolution source. The controller MUST therefore push the flex-algo definitions and BGP next-hop resolution config to all devices when `enabled: true`. Admin-group tagging on interfaces is applied only to links with a non-empty `link_colors` that are not in the `link_tagging.exclude.links` list. #### Multicast path isolation Multicast (PIM) resolves via the IS-IS unicast RIB (algo 0), which uses all links regardless of color. This is inherent to how PIM RPF works — it is not affected by the BGP next-hop resolution profile, and `next-hop resolution ribs` does not support multicast address families. Multicast isolation does not depend on any additional configuration — PIM RPF resolves via the unicast RIB regardless of how VPN unicast is steered. -| Service | Path | UNICAST-DEFAULT links used? | +| Service | Path | Links in path | |---|---|---| -| VPN unicast | flex-algo 128 (`system-colored-tunnel-rib`, color 1) | Yes — only tagged links | -| Multicast (PIM, default VRF) | IS-IS algo 0 (unicast RIB) | Yes — all links including tagged | +| VPN unicast | flex-algo 128 (`system-colored-tunnel-rib`, color 1) | Tagged links only | +| Multicast (PIM, default VRF) | IS-IS algo 0 (unicast RIB) | All links | --- ### BGP Color Extended Community -VPN-IPv4 routes MUST carry a BGP color extended community (`Color:CO(00):`) on export. The color value matches the EOS flex-algo `color` field for the target topology. At receiving devices, BGP next-hop resolution uses `system-colored-tunnel-rib` to match the (next-hop, color) pair to a flex-algo tunnel. +VPN-IPv4 routes MUST carry a BGP color extended community (`Color:CO(00):`) inbound on the client-facing BGP session. The color value matches the EOS flex-algo `color` field for the target topology. At receiving devices, BGP next-hop resolution uses `system-colored-tunnel-rib` to match the (next-hop, color) pair to a flex-algo tunnel. #### Next-hop resolution -All devices MUST be configured with the following BGP next-hop resolution profile: +All devices MUST be configured with the following BGP next-hop resolution profile when `enabled: true`: ``` router bgp 65342 @@ -265,19 +315,25 @@ router bgp 65342 #### Inbound route-map color stamping -The controller already generates a `RM-USER-{{ .Id }}-IN` route-map per tunnel, applied inbound on each client-facing BGP session. This route-map currently sets standard communities identifying the user as unicast or multicast and tagging the originating exchange. The color extended community is added as an additional `set` statement in this same route-map, applied only to unicast tunnels and only when link colors are defined: +The controller already generates a `RM-USER-{{ .Id }}-IN` route-map per tunnel, applied inbound on each client-facing BGP session. This route-map currently sets standard communities identifying the user as unicast or multicast and tagging the originating exchange. The color extended community is added as an additional `set` statement in this same route-map, applied only to unicast tunnels and only when the controller config enables stamping for the tenant and device: ``` route-map RM-USER-{{ .Id }}-IN permit 10 match ip address prefix-list PL-USER-{{ .Id }} match as-path length = 1 set community 21682:{{ if eq true .IsMulticast }}1300{{ else }}1200{{ end }} 21682:{{ $.Device.BgpCommunity }} - {{- if and $.Features.FlexAlgo (not .IsMulticast) $.LinkColors }} - set extcommunity color {{ .TenantTopologyEosColorValue }} + {{- if and $.Config.FlexAlgo.Enabled (not .IsMulticast) $.LinkColors ($.Config.FlexAlgo.CommunityStamping.ShouldStamp .TenantPubKey $.Device.PubKey) }} + set extcommunity color {{ .TenantTopologyEosColorValues }} {{- end }} ``` -`.TenantTopologyEosColorValue` is resolved by the controller from the tunnel's tenant: if the tenant has a `topology_color` pubkey set, resolve the `LinkColorInfo` and compute `AdminGroupBit + 1`; otherwise use the default unicast color (color 1, the first `LinkColorInfo` by `AdminGroupBit`). Multicast tunnels do not receive the color community — multicast RPF resolves via IS-IS algo 0 and does not use `system-colored-tunnel-rib`. +`.TenantTopologyEosColorValues` is resolved by the controller from the tunnel's tenant: +- If `tenant.include_topology_colors` is non-empty, resolve each `LinkColorInfo` PDA and compute `AdminGroupBit + 1` for each. All resolved color values are stamped in a single `set extcommunity color` statement (e.g., `set extcommunity color 1 color 2`). +- If `tenant.include_topology_colors` is empty, use the default unicast color: resolve the `LinkColorInfo` where `admin_group_bit == 0` (UNICAST-DEFAULT, EOS color value 1). + +When multiple colors are stamped, EOS selects the colored tunnel with the lowest IGP metric to the next-hop. If two colors tie on metric, the highest color number wins. If a preferred color's tunnel becomes unavailable (e.g., the destination withdraws its node-segment for that algorithm), EOS automatically falls back to the next-best available color — the route remains installed throughout with no disruption. This fallback behavior has been verified in lab testing. + +Multicast tunnels do not receive the color community — multicast RPF resolves via IS-IS algo 0 and does not use `system-colored-tunnel-rib`. Routes arrive on the client-facing session, are stamped with both the standard community and the color extended community in a single pass, and are then advertised into VPN-IPv4 carrying both. No new route-map blocks or `network` statement changes are required. @@ -285,7 +341,7 @@ Routes arrive on the client-facing session, are stamped with both the standard c ### Controller Changes -All config changes are applied to `tunnel.tmpl`. Five additions are required. All blocks are conditioned on `and .Features.FlexAlgo .LinkColors` — when the `FlexAlgo` feature flag is unset, no flex-algo config is generated regardless of onchain link color state. +All config changes are applied to `tunnel.tmpl`. Five additions are required. All blocks are conditioned on `$.Config.FlexAlgo.Enabled` — when disabled, no flex-algo config is generated and the controller generates `no` commands to remove any previously-pushed flex-algo config. #### 1. Interface admin-group tagging @@ -294,22 +350,27 @@ Inside the existing `{{- range .Device.Interfaces }}` block, after the `isis met ``` {{- if and .Ip.IsValid .IsPhysical .Metric .IsLink (not .IsSubInterfaceParent) (not .IsCYOA) (not .IsDIA) }} traffic-engineering - {{- if .LinkColor }} - traffic-engineering administrative-group {{ $.Strings.ToUpper .LinkColor.Name }} + {{- if and .LinkColors (not ($.Config.FlexAlgo.LinkTagging.IsExcluded .PubKey)) }} + traffic-engineering administrative-group {{ $.Strings.Join " " ($.Strings.ToUpperEach .LinkColorNames) }} {{- else }} no traffic-engineering administrative-group {{- end }} {{- end }} ``` -`.LinkColor` is nil when `link.link_color` is `Pubkey::default()`. The `no traffic-engineering administrative-group` line ensures stale config is removed when a color is cleared. Interface-level admin-group tagging is conditioned on `.Features.FlexAlgo` alone — not on `.LinkColors` — since an interface may have a color assigned onchain while the feature flag is unset. +`.LinkColors` is the resolved list of `LinkColorInfo` accounts from `link.link_colors`; it is empty when `link_colors` is empty. `.LinkColorNames` is the corresponding list of names. The controller renders all colors as a space-separated list in a single command — EOS overwrites the existing admin-group assignment with exactly this set. This means: +- A link transitioning from two colors to one re-applies only the surviving color, atomically replacing the previous set +- A link losing its last color receives `no traffic-engineering administrative-group` +- The targeted `no traffic-engineering administrative-group ` command is never used, avoiding the EOS behavior where it would remove all groups regardless of the name specified + +Interface-level admin-group tagging is conditioned on `$.Config.FlexAlgo.Enabled` alone — since an interface may have colors assigned onchain while the feature is disabled. #### 2. router traffic-engineering block -Add after the `router isis 1` block, conditional on colors being defined and the feature flag being set: +Add after the `router isis 1` block, conditional on colors being defined and the feature being enabled: ``` -{{- if and .Features.FlexAlgo .LinkColors }} +{{- if and $.Config.FlexAlgo.Enabled .LinkColors }} router traffic-engineering router-id ipv4 {{ .Device.Vpn4vLoopbackIP }} {{- range .LinkColors }} @@ -331,9 +392,11 @@ router traffic-engineering `.LinkColors` is the ordered list of `LinkColorInfo` accounts, sorted by `AdminGroupBit`. `.EosColorValue` is computed as `AdminGroupBit + 1`. The flex-algo name (e.g., `unicast-default`) is the color name stored in `LinkColorInfo`. +When `$.Config.FlexAlgo.Enabled` is false, the controller generates `no router traffic-engineering` to remove any previously-pushed config. + #### 3. BGP next-hop resolution -Inside the existing `address-family vpn-ipv4` block, replace any existing `next-hop resolution ribs` line with: +Inside the existing `address-family vpn-ipv4` block: ``` address-family vpn-ipv4 @@ -345,7 +408,7 @@ Inside the existing `address-family vpn-ipv4` block, replace any existing `next- {{- range .UnknownBgpPeers }} no neighbor {{ . }} {{- end }} - {{- if and .Features.FlexAlgo .LinkColors }} + {{- if and $.Config.FlexAlgo.Enabled .LinkColors }} next-hop resolution ribs tunnel-rib colored system-colored-tunnel-rib system-connected {{- end }} ! @@ -353,22 +416,20 @@ Inside the existing `address-family vpn-ipv4` block, replace any existing `next- #### 4. IS-IS flex-algo advertisement and traffic-engineering -Inside the existing `router isis 1` block, two additions are required, both conditional on colors being defined. - -Under `segment-routing mpls`, add a `flex-algo` advertisement line per color so the device participates in each constrained topology and advertises the corresponding flex-algo to its IS-IS neighbors: +Inside the existing `router isis 1` block, under `segment-routing mpls`, add a `flex-algo` advertisement line per color: ``` segment-routing mpls no shutdown {{- range .LinkColors }} - flex-algo {{ .FlexAlgoNumber }} level-2 advertised + flex-algo {{ .Name }} level-2 advertised {{- end }} ``` -After the `segment-routing mpls` block, add the `traffic-engineering` section that enables IS-IS TE on the device, which is required for admin-group advertisements to appear in the LSDB: +After the `segment-routing mpls` block, add the `traffic-engineering` section: ``` -{{- if and .Features.FlexAlgo .LinkColors }} +{{- if and $.Config.FlexAlgo.Enabled .LinkColors }} traffic-engineering no shutdown is-type level-2 @@ -379,7 +440,7 @@ Without both of these, the device does not advertise its flex-algo node-SIDs and #### 5. Loopback flex-algo node-segment -Inside the existing `{{- range .Device.Interfaces }}` block, extend the existing `node-segment` line on the Vpn4vLoopback interface to also advertise a flex-algo node-SID per color: +Inside the existing `{{- range .Device.Interfaces }}` block, extend the existing `node-segment` line on the Vpn4vLoopback interface: ``` {{- if and .IsVpnv4Loopback .NodeSegmentIdx }} @@ -394,17 +455,19 @@ Inside the existing `{{- range .Device.Interfaces }}` block, extend the existing The flex-algo node-segment index follows the same pattern as the existing algo-0 `node_segment_idx`: it is allocated from the `SegmentRoutingIds` `ResourceExtension` account at interface activation time (in `processors/device/interface/activate.rs`) and stored on the `Interface` account onchain. A new `flex_algo_node_segment_idx: u16` field MUST be added to the `Interface` account and allocated alongside `node_segment_idx` when the interface's loopback type is `Vpnv4`. It is deallocated on `remove`, following the existing pattern. -**Migration for existing interfaces:** Vpn4v loopback interfaces that were activated before this RFC will not have `flex_algo_node_segment_idx` allocated. A one-time `doublezero-admin` CLI migration command MUST be provided to iterate all existing `Interface` accounts with `loopback_type = Vpnv4`, allocate a `flex_algo_node_segment_idx` for each, and persist the updated account. This migration MUST be run before the `FlexAlgo` feature flag is enabled; without it, existing devices will not advertise flex-algo node-SIDs and will be unreachable via the constrained topology. +**Migration for existing interfaces:** Vpn4v loopback interfaces that were activated before this RFC will not have `flex_algo_node_segment_idx` allocated. Existing `node_segment_idx` assignments (algo-0, used today) are unchanged — this migration is purely additive. A one-time `doublezero-admin` CLI migration command MUST be provided to iterate all existing `Interface` accounts with `loopback_type = Vpnv4`, allocate a `flex_algo_node_segment_idx` for each, and persist the updated account. Loopbacks activated after this RFC will have `flex_algo_node_segment_idx` allocated at activation time alongside `node_segment_idx`. + +The controller MUST check at startup, before enabling flex-algo, that no Vpn4v loopback has `flex_algo_node_segment_idx == 0`. If any unset loopbacks are found, the controller MUST refuse to apply flex-algo config and emit an error directing the operator to run the migration command. This prevents silently pushing a broken topology where some devices are unreachable via the constrained path. Without a flex-algo node-SID on the loopback, remote devices cannot compute a valid constrained path to this device and VPN routes to it will not resolve via the colored tunnel RIB. -All seven blocks are conditional on `.LinkColors` being non-empty, so devices with no colors defined produce identical config to today. +All blocks are conditional on `.LinkColors` being non-empty, so devices with no colors defined produce identical config to today. --- ### SDK Changes -`LinkColorInfo` MUST be added to the Go, Python, and TypeScript SDKs. The `link` deserialization structs MUST include the new `link_color` pubkey field. Fixture files MUST be regenerated. +`LinkColorInfo` MUST be added to the Go, Python, and TypeScript SDKs. The `link` deserialization structs MUST include the new `link_colors: Vec` field. The `tenant` deserialization structs MUST include the new `include_topology_colors: Vec` field. Fixture files MUST be regenerated. --- @@ -413,56 +476,73 @@ All seven blocks are conditional on `.LinkColors` being non-empty, so devices wi #### Smart contract (integration tests) **LinkColorInfo lifecycle:** -- A foundation key MUST be able to create a `LinkColorInfo` account with a name; admin-group bit and flex-algo number MUST be auto-assigned starting at 0 and 128 respectively. -- Creating a second color MUST auto-assign bit 1 and flex-algo 129. +- A foundation key MUST be able to create a `LinkColorInfo` account with a name; admin-group bit MUST be allocated from the `AdminGroupBits` `ResourceExtension` starting at 0, and flex-algo number MUST be 128. +- Creating a second color MUST allocate bit 1 from the `ResourceExtension` and flex-algo 129. - A non-foundation key MUST NOT be able to create a `LinkColorInfo` account; the instruction MUST be rejected with an authorization error. - All `LinkColorInfo` fields are immutable after creation; an `update` instruction MUST be rejected or be a no-op. - A non-foundation key MUST NOT be able to update a `LinkColorInfo` account. - `delete` MUST succeed when no links reference the color; the `LinkColorInfo` PDA MUST be removed onchain. - `delete` MUST fail when one or more links still reference the color. -- After `clear`, all links previously assigned the color MUST have `link_color = Pubkey::default()`. +- After `clear`, all links previously assigned the color MUST have `link_colors = []`. - After `clear` followed by `delete`, the `LinkColorInfo` PDA MUST be absent. -- Admin-group bits from deleted colors MUST NOT be reused by subsequently created colors. +- Admin-group bits from deleted colors MUST NOT be reused by subsequently created colors; the `AdminGroupBits` `ResourceExtension` bitmap MUST persist the allocated bit after PDA deletion. - After `delete`, the controller MUST NOT generate removal commands for the deleted color's admin-group alias, flex-algo definition, or IS-IS TE config — device-side cleanup is deferred. +**Tenant topology color assignment:** +- `include_topology_colors` MUST default to an empty vector on a newly created tenant account and on existing accounts deserialized from pre-upgrade binary data. +- A foundation key MUST be able to set `include_topology_colors` to a list of valid `LinkColorInfo` pubkeys on any tenant. +- A non-foundation key MUST NOT be able to set `include_topology_colors`; the instruction MUST be rejected with an authorization error. +- Setting `include_topology_colors` to an empty vector MUST be accepted and revert the tenant to the default color 1 (UNICAST-DEFAULT). +- Setting `include_topology_colors` to a pubkey that does not correspond to a valid `LinkColorInfo` account MUST be rejected. + **Link color assignment:** -- `link_color` MUST default to `Pubkey::default()` on a newly created link account and on existing accounts deserialized from pre-upgrade binary data. -- A foundation key MUST be able to set `link_color` to a valid `LinkColorInfo` pubkey on any link. -- A contributor key MUST NOT be able to set `link_color`; the instruction MUST be rejected with an authorization error. -- Setting `link_color` to `Pubkey::default()` from a non-default color MUST be accepted and persist correctly. -- Setting `link_color` to a pubkey that does not correspond to a valid `LinkColorInfo` account MUST be rejected. +- `link_colors` MUST default to an empty vector on a newly created link account and on existing accounts deserialized from pre-upgrade binary data. +- A foundation key MUST be able to set `link_colors[0]` to a valid `LinkColorInfo` pubkey on any link. +- A contributor key MUST NOT be able to set `link_colors`; the instruction MUST be rejected with an authorization error. +- Setting `link_colors` to an empty vector from a non-empty value MUST be accepted and persist correctly. +- Setting `link_colors[0]` to a pubkey that does not correspond to a valid `LinkColorInfo` account MUST be rejected. +- `link_colors` MUST NOT exceed 8 entries; an instruction submitting more than 8 MUST be rejected. #### Controller (unit tests) -- A link with `link_color = Pubkey::default()` MUST produce interface config with no `traffic-engineering administrative-group` line. -- A link with `link_color` referencing a `LinkColorInfo` with bit 0, name "unicast-default", and constraint `IncludeAny` MUST produce interface config with `traffic-engineering administrative-group UNICAST-DEFAULT`. +- A link with `link_colors = []` MUST produce interface config with no `traffic-engineering administrative-group` line. +- A link with `link_colors[0]` referencing a `LinkColorInfo` with bit 0, name "unicast-default", and constraint `IncludeAny` MUST produce interface config with `traffic-engineering administrative-group UNICAST-DEFAULT`. +- A link in `link_tagging.exclude.links` MUST produce `no traffic-engineering administrative-group` regardless of onchain `link_colors` assignment. - Transitioning a link from a color to default MUST produce a `no traffic-engineering administrative-group` diff. - Transitioning a link from one color to another MUST produce the correct remove/add diff. - The `router traffic-engineering` block MUST include `color ` on each flex-algo definition. -- The BGP `next-hop resolution ribs tunnel-rib colored system-colored-tunnel-rib system-connected` config MUST be generated correctly. -- A per-tunnel inbound route-map MUST include `set extcommunity color 1` for unicast tunnels when the `FlexAlgo` flag is set and `LinkColors` is non-empty. +- The BGP `next-hop resolution ribs tunnel-rib colored system-colored-tunnel-rib system-connected` config MUST be generated correctly when `enabled: true`. +- A per-tunnel inbound route-map MUST include `set extcommunity color 1` for a unicast tenant with empty `include_topology_colors` when the tenant or device is in the `community_stamping` config and `LinkColors` is non-empty. +- A per-tunnel inbound route-map MUST include `set extcommunity color 1 color 2` for a unicast tenant with `include_topology_colors` referencing two `LinkColorInfo` accounts (bits 0 and 1). +- A per-tunnel inbound route-map MUST NOT include `set extcommunity color` when the device is in `community_stamping.exclude.devices`. - A new `LinkColorInfo` account detected on reconciliation MUST cause the controller to push updated config to all devices. -- **Feature flag — unset:** With `LinkColorInfo` accounts defined, links tagged, and `FlexAlgo` feature flag unset, the controller MUST generate no `router traffic-engineering` block, no flex-algo IS-IS config, no `next-hop resolution ribs` line, and no `set extcommunity color` in any route-map. Device config MUST be identical to a network with no colors defined. -- **Feature flag — set:** Enabling the `FlexAlgo` flag with existing `LinkColorInfo` accounts and tagged links MUST cause the controller to generate the full flex-algo config block on the next reconciliation cycle. -- **Feature flag — interface tagging independent:** Interface-level `traffic-engineering administrative-group` config MUST be generated based on `Features.FlexAlgo` alone, regardless of whether `.LinkColors` is populated, ensuring tagged interfaces are correctly configured once the flag is set. +- **Config disabled:** With `LinkColorInfo` accounts defined, links tagged, and `enabled: false`, the controller MUST generate `no router traffic-engineering`, no flex-algo IS-IS config, no `next-hop resolution ribs` line, and no `set extcommunity color` in any route-map. Device config MUST be identical to a network with no colors defined. +- **Config enabled:** Setting `enabled: true` with existing `LinkColorInfo` accounts and tagged links MUST cause the controller to generate the full flex-algo config block on the next reconciliation cycle. +- **Interface tagging independent of stamping:** Interface-level `traffic-engineering administrative-group` config MUST be generated based on `$.Config.FlexAlgo.Enabled` alone, regardless of `community_stamping` settings. #### SDK (unit tests) - `LinkColorInfo` account MUST serialize and deserialize correctly via Borsh for all fields. - `LinkColorInfo` account MUST deserialize correctly from a binary fixture. -- `link_color` pubkey field MUST be included in `link get` and `link list` output in all three SDKs, showing the color name (resolved from `LinkColorInfo`) or "default". +- `link_colors` pubkey vector MUST be included in `link get` and `link list` output in all three SDKs, showing the color names (resolved from `LinkColorInfo`) or "default". - The `list` command MUST display the derived EOS color value (`admin_group_bit + 1`) in output. #### End-to-end (cEOS testcontainers) -- **Color creation**: After a foundation key creates a `LinkColorInfo` for "unicast-default" (bit 0, flex-algo 128, constraint include-any), the controller MUST push `router traffic-engineering` config with `administrative-group alias UNICAST-DEFAULT group 0`, `flex-algo 128 unicast-default administrative-group include any 0 color 1`, and the BGP `next-hop resolution ribs` line to all devices. -- **Admin-group application**: After a foundation key sets `link_color` on a link to the unicast-default `LinkColorInfo` pubkey, `show traffic-engineering database` on the connected devices MUST reflect `UNICAST-DEFAULT` admin-group on the interface. Setting the color back to default MUST remove the admin-group. +- **Color creation**: After a foundation key creates a `LinkColorInfo` for "unicast-default" (bit 0, flex-algo 128, constraint include-any) and `enabled: true` is set, the controller MUST push `router traffic-engineering` config with `administrative-group alias UNICAST-DEFAULT group 0`, `flex-algo 128 unicast-default administrative-group include any 0 color 1`, and the BGP `next-hop resolution ribs` line to all devices. +- **Admin-group application**: After a foundation key sets `link_colors[0]` on a link to the unicast-default `LinkColorInfo` pubkey, `show traffic-engineering database` on the connected devices MUST reflect `UNICAST-DEFAULT` admin-group on the interface. Clearing the color MUST remove the admin-group. +- **Link tagging exclude**: A link in `link_tagging.exclude.links` MUST NOT have an admin-group applied even when `link_colors[0]` is set onchain. - **Flex-algo topology**: With links tagged UNICAST-DEFAULT, `show isis flex-algo` on participating devices MUST show algo 128 including only UNICAST-DEFAULT links. Untagged links MUST be absent from the algo-128 LSDB view. - **Colored tunnel RIB**: `show tunnel rib system-colored-tunnel-rib brief` MUST show (endpoint, color 1) entries for each participating device, resolving via unicast-default tunnels. - **VPN unicast path selection**: A BGP VPN-IPv4 route carrying `Color:CO(00):1` MUST resolve its next-hop through the color-1 (unicast-default) tunnel in `system-colored-tunnel-rib`, traversing only UNICAST-DEFAULT tagged links. -- **Route-map color community**: `show bgp vpn-ipv4 detail` MUST show `Color:CO(00):1` on exported VPN-IPv4 routes from all tenant VRFs. +- **Per-tenant color — single**: A tenant with `include_topology_colors = [SHELBY pubkey]` MUST have `Color:CO(00):2` stamped on its inbound routes. A tenant with empty `include_topology_colors` (default) MUST have `Color:CO(00):1` (UNICAST-DEFAULT). +- **Per-tenant color — multi**: A tenant with `include_topology_colors = [UNICAST-DEFAULT pubkey, SHELBY pubkey]` MUST have both `Color:CO(00):1` and `Color:CO(00):2` stamped. `show ip route vrf ` MUST show the lower-metric color tunnel selected for next-hop resolution. +- **Per-tenant color — fallback**: Removing a device's node-segment for the preferred color algorithm MUST cause EOS to fall back to the next available color on the same prefix without the route going unresolved. +- **Community stamping — per device**: A tenant on a device in `community_stamping.devices` MUST have the color community on its inbound routes. The same tenant on a device NOT in the config MUST NOT have the color community. +- **Community stamping — exclude**: A device in `community_stamping.exclude.devices` MUST NOT have `set extcommunity color` applied regardless of `all` or `tenants` settings. - **Multicast path isolation**: PIM RPF for a multicast source MUST continue to resolve via IS-IS algo 0 (all links, including both tagged and untagged) regardless of BGP next-hop resolution config. - **Color clear**: After `link color clear --name unicast-default` removes the color from all links, the controller MUST generate `no traffic-engineering administrative-group UNICAST-DEFAULT` on all previously-tagged interfaces on the next reconciliation cycle. +- **Revert**: Setting `enabled: false` and restarting the controller MUST result in all flex-algo config being removed from all devices on the next reconciliation cycle. #### EOS Verification @@ -506,7 +586,7 @@ MUST confirm BGP VPN-IPv4 routes carry `Color:CO(00):1` and resolve next-hops th show traffic-engineering database show traffic-engineering interfaces ``` -MUST confirm admin-group membership is visible only on interfaces with a non-default link color. +MUST confirm admin-group membership is visible only on interfaces with a non-empty `link_colors`. **Verify multicast RPF uses algo-0 (including colored links):** ``` @@ -520,43 +600,44 @@ MUST confirm PIM RPF resolves via the IS-IS unicast RIB (algo 0). The incoming i ### Codebase -- **serviceability** — new `LinkColorInfo` PDA (foundation-managed, one per color); new `link_color: Pubkey` field on `Link`; new `link_color: Option` field on `LinkUpdateArgs` with foundation-only write restriction; new `flex_algo_node_segment_idx: u16` field on `Interface`; `FlexAlgo = 2` added to `FeatureFlag` enum. -- **controller** — reads `GlobalState.feature_flags` to gate all flex-algo config on the `FlexAlgo` flag; reads `link.link_color`, resolves `LinkColorInfo` PDAs, generates IS-IS TE admin-group config on interfaces, flex-algo definitions with `color` field, `system-colored-tunnel-rib` BGP resolution profile, and adds `set extcommunity color` to the existing per-tunnel inbound route-maps (`RM-USER-{{ .Id }}-IN`). -- **CLI** — full color lifecycle commands (`create`, `update`, `delete`, `clear`, `list`); `link update` gains `--link-color`; `link get` / `link list` display the field including derived EOS color value. -- **SDKs** — `LinkColorInfo` added to all three language SDKs; `link_color` field added to link deserialization structs; `FlexAlgo` flag added to feature flag constants. +- **serviceability** — new `LinkColorInfo` PDA (foundation-managed, one per color); new `AdminGroupBits` `ResourceExtension` account for persistent bit allocation; new `link_colors: Vec` field (cap 8) on `Link`; new `link_colors: Option>` field on `LinkUpdateArgs` with foundation-only write restriction; new `flex_algo_node_segment_idx: u16` field on `Interface`; new `include_topology_colors: Vec` field on `Tenant` with foundation-only write restriction. +- **controller** — new `-features-config` flag and `features.yaml` config file; reads `link.link_colors[0]`, resolves `LinkColorInfo` PDAs, generates IS-IS TE admin-group config on interfaces (respecting `link_tagging.exclude.links`), flex-algo definitions with `color` field, `system-colored-tunnel-rib` BGP resolution profile, and adds `set extcommunity color` to the existing per-tunnel inbound route-maps (`RM-USER-{{ .Id }}-IN`) for stamping-eligible tunnels; generates `no` commands for full revert when `enabled: false`. +- **CLI** — full color lifecycle commands (`create`, `update`, `delete`, `clear`, `list`); `link update` gains `--link-color`; `link get` / `link list` display the field including derived EOS color value; `link color list` warns on disconnected topologies. +- **SDKs** — `LinkColorInfo` added to all three language SDKs; `link_colors` field added to link deserialization structs. ### Operational -- DZF MUST create a `LinkColorInfo` account and assign `link_color` on links before the controller applies TE admin-groups. Until a color is created and assigned, links behave as today. -- Adding a new color MUST NOT require a code change or deploy — DZF creates the `LinkColorInfo` account via the CLI and the controller picks it up on the next reconciliation cycle. -- `link_color` appends to the serialized layout and defaults to `Pubkey::default()` on existing accounts. No migration is required. +- DZF MUST create a `LinkColorInfo` account and assign `link_colors` on links before the controller applies TE admin-groups. Until a color is created and assigned, links behave as today. +- Adding a new color MUST NOT require a code change or deploy — DZF creates the `LinkColorInfo` account via the CLI and the controller picks it up on the next reconciliation cycle once `enabled: true`. +- `link_colors` appends to the serialized layout and defaults to an empty vector on existing accounts. No migration is required. - The transition from no-color to color-1 on all tenant VRFs is a one-time controller config push. The template section order enforces the correct sequencing within a single reconciliation cycle: the `router traffic-engineering` block and `address-family vpn-ipv4 next-hop resolution` config appear before the `route-map RM-USER-*-IN` blocks in `tunnel.tmpl`, so EOS applies them top-to-bottom in the correct order. Applying the route-map before the RIB is configured would cause VPN routes to go unresolved. ### Testing | Layer | Tests | |---|---| -| Smart contract | `LinkColorInfo` create/update/delete lifecycle; foundation-only authorization; auto-assignment of bit and flex-algo number; `link_color` assignment and clearing; delete blocked when links assigned; `clear` removes color from all links | -| Controller (unit) | Interface config with and without color; remove/add diff on color change; `router traffic-engineering` block with `color` field; `system-colored-tunnel-rib` BGP resolution config; per-VRF route-map generation; new color triggers config push to all devices | -| SDK (unit) | `LinkColorInfo` Borsh round-trip; `link_color` field in `link get` / `link list` output across Go, Python, and TypeScript; EOS color value displayed correctly | -| End-to-end (cEOS) | Color create → controller config push verified; admin-group applied and removed on interface; flex-algo 128 topology with `color 1` excludes colored link; `system-colored-tunnel-rib` populated; VPN unicast resolves via color-1 tunnel; BGP VPN-IPv4 routes carry correct color community; multicast RPF unchanged on untagged links; color clear removes admin-groups from interfaces; onchain delete removes PDA | +| Smart contract | `LinkColorInfo` create/update/delete lifecycle; `AdminGroupBits` `ResourceExtension` allocation and no-reuse after deletion; foundation-only authorization; `link_colors` assignment and clearing; delete blocked when links assigned; `clear` removes color from all links; `link_colors` cap at 8 enforced; `include_topology_colors` assignment on tenant; foundation-only authorization | +| Controller (unit) | Interface config with and without color; link tagging exclude list respected; remove/add diff on color change; `router traffic-engineering` block with `color` field; `system-colored-tunnel-rib` BGP resolution config; single-color and multi-color per-tenant stamping; community stamping per-tenant and per-device; stamping exclude respected; full revert on `enabled: false`; new color triggers config push to all devices | +| SDK (unit) | `LinkColorInfo` Borsh round-trip; `link_colors` field in `link get` / `link list` output; `include_topology_colors` field in `tenant get` / `tenant list` output across Go, Python, and TypeScript; EOS color value displayed correctly | +| End-to-end (cEOS) | Color create → controller config push verified; admin-group applied and removed on interface; link tagging exclude list verified; flex-algo 128 topology includes only UNICAST-DEFAULT links; `system-colored-tunnel-rib` populated; VPN unicast resolves via color-1 tunnel; per-tenant single and multi-color stamping verified; color fallback verified; community stamping per-device verified; stamping exclude verified; multicast RPF unchanged; color clear removes admin-groups; full revert on `enabled: false` verified | --- ## Security Considerations -- `link_color` MUST only be writable by foundation keys. A contributor MUST NOT be able to tag their own link with a color to influence path steering. The check mirrors the existing pattern used for `link.status` foundation-override. +- `link_colors` MUST only be writable by foundation keys. A contributor MUST NOT be able to tag their own link with a color to influence path steering. The check mirrors the existing pattern used for `link.status` foundation-override. - `LinkColorInfo` accounts MUST only be created, updated, or deleted by foundation keys. +- The controller feature config file (`features.yaml`) is a local file on the controller host. Access to this file SHOULD be restricted to the operator running the controller. An attacker with write access to the config file could enable or disable flex-algo config or manipulate the stamping allowlist without an onchain transaction. - If a foundation key is compromised, an attacker could reclassify links or create new color definitions, causing traffic to be steered onto unintended paths. This is the same threat surface as other foundation-key-controlled fields. No new mitigations are introduced. --- ## Backward Compatibility -- The `link_color` field MUST default to `Pubkey::default()` (no color assigned) on existing accounts. No migration is required. +- `link_colors` appends to the serialized layout and defaults to an empty vector on existing accounts. No migration is required for the onchain schema. - Devices that do not receive updated config from the controller MUST continue to forward using IS-IS algo 0 only. The flex-algo topology is distributed — a device that does not participate is simply not included in the constrained SPF. -- The controller MUST push `system-colored-tunnel-rib` config and flex-algo definitions before activating per-VRF route-maps. Activating route-maps first causes VPN routes to carry a color community with no matching tunnel RIB entry, leaving them unresolved. -- `tunnel-rib` config generated by the controller uses a single BGP next-hop resolution profile applied globally. Per-tenant resolution profile overrides are not supported at this stage. +- The controller MUST push `system-colored-tunnel-rib` config and flex-algo definitions before activating per-VRF route-maps. Activating route-maps first causes VPN routes to carry a color community with no matching tunnel RIB entry, leaving them unresolved. The template section order enforces this within a single reconciliation cycle. +- The BGP next-hop resolution profile is applied globally. Per-tenant resolution profile overrides are not supported at this stage. --- From 4d0d86516a75ffb627653fa447889a899438e57e Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 30 Mar 2026 17:33:28 -0500 Subject: [PATCH 03/49] =?UTF-8?q?rfc18:=20rename=20'color'=20=E2=86=92=20'?= =?UTF-8?q?topology'=20throughout;=20address=20packethog=20review=20commen?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR #3288 review feedback and previous second-pass review findings: - Rename DZF concept: 'color/link color' → 'topology/link topology' throughout the RFC wherever referring to the constrained IS-IS forwarding plane concept. EOS/BGP protocol terms retained as-is: EOS color value, BGP color extended community, system-colored-tunnel-rib, set extcommunity color, Color:CO(00):N. Adds 'Topology vs color' terminology entry to make the distinction explicit. - Fix resolution RIB profile (packethog line 412): replace system-connected with tunnel-rib system-tunnel-rib in template, prose, test requirements, verification tests, and asymmetric routing description. Confirmed correct via lab test configs. - Fix flex-algo node segment data model (packethog line 451): replace single flex_algo_node_segment_idx: u16 on Interface with Vec (topology: Pubkey, node_segment_idx: u16). Fixes multi-topology support and moves node segment index to TopologyInfo scope. Loopback template updated to iterate .FlexAlgoNodeSegments directly. - Add auto-tagging at activation: activation processor sets link_topologies[0] to UNICAST-DEFAULT pubkey automatically; fails explicitly if UNICAST-DEFAULT does not exist. - Add bootstrapping deployment procedure and migration enforcement: controller treats enabled: true as no-op if unset loopbacks are found; migration command covers both link tagging and loopback node segment allocation; idempotent with --dry-run and summary output. - Rename CLI flags and struct fields: --link-color → --link-topology, --include-topology-colors → --include-topologies, LinkColorInfo → TopologyInfo, link_colors → link_topologies, include_topology_colors → include_topologies, LinkColorConstraint → TopologyConstraint, b"link-color-info" → b"topology", exclude_topology_colors → exclude_topologies. - Alphabetize New Terminology section; rename multicast table column to 'Links in path'; update default color framing to clarify all unicast tenants receive color 1 by default with include_topologies as override. Co-Authored-By: Claude Sonnet 4.6 --- rfcs/rfc18-link-classification-flex-algo.md | 361 +++++++++++--------- 1 file changed, 195 insertions(+), 166 deletions(-) diff --git a/rfcs/rfc18-link-classification-flex-algo.md b/rfcs/rfc18-link-classification-flex-algo.md index 45c989399e..69df68730d 100644 --- a/rfcs/rfc18-link-classification-flex-algo.md +++ b/rfcs/rfc18-link-classification-flex-algo.md @@ -6,17 +6,17 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119). -DoubleZero contributors operate links with different physical characteristics — low latency, high bandwidth, or both. Today all traffic uses the same IS-IS topology, so every service follows the same paths regardless of what those paths are optimized for. This RFC introduces a link classification model that allows DZF to assign named color labels to links onchain and use IS-IS Flexible Algorithm (flex-algo) to compute separate constraint-based forwarding topologies per color. Different traffic classes — VPN unicast and IP multicast — can then use different topologies. +DoubleZero contributors operate links with different physical characteristics — low latency, high bandwidth, or both. Today all traffic uses the same IS-IS topology, so every service follows the same paths regardless of what those paths are optimized for. This RFC introduces a link classification model that allows DZF to assign named topology labels to links onchain and use IS-IS Flexible Algorithm (flex-algo) to compute separate constraint-based forwarding topologies per label. Different traffic classes — VPN unicast and IP multicast — can then use different topologies. **Deliverables:** -- `LinkColorInfo` onchain account — DZF creates this to define a color, with auto-assigned admin-group bit (from the `AdminGroupBits` `ResourceExtension`), flex-algo number, and derived EOS color value -- `link_colors: Vec` field on the serviceability link account — references assigned colors; capped at 8 entries; only the first entry is used by the controller in this RFC +- `TopologyInfo` onchain account — DZF creates this to define a topology, with auto-assigned admin-group bit (from the `AdminGroupBits` `ResourceExtension`), flex-algo number, and derived EOS color value +- `link_topologies: Vec` field on the serviceability link account — references assigned topologies; capped at 8 entries; only the first entry is used by the controller in this RFC - Controller feature config file (`features.yaml`) — loaded at startup; gates flex-algo topology config, link admin-group tagging, and BGP color community stamping independently; replaces any onchain feature flag for this capability -- Controller logic — translates colors into IS-IS TE admin-groups on interfaces, generates flex-algo topology definitions, configures `system-colored-tunnel-rib` as the BGP next-hop resolution source, and applies BGP color extended community route-maps per tunnel; all conditioned on the controller config +- Controller logic — translates topologies into IS-IS TE admin-groups on interfaces, generates flex-algo topology definitions, configures `system-colored-tunnel-rib` as the BGP next-hop resolution source, and applies BGP color extended community route-maps per tunnel; all conditioned on the controller config **Scope:** - Delivers traffic-class-level segregation: multicast vs. VPN unicast at the network level -- Per-tenant unicast path differentiation via `include_topology_colors: Vec` on the `Tenant` account — all unicast tenants receive color 1 (UNICAST-DEFAULT) by default; `include_topology_colors` overrides this to assign specific color values and steer the tenant onto a designated topology +- Per-tenant unicast path differentiation via `include_topologies: Vec` on the `Tenant` account — all unicast tenants receive color 1 (UNICAST-DEFAULT) by default; `include_topologies` overrides this to assign specific topologies and steer the tenant onto a designated forwarding plane --- @@ -39,10 +39,11 @@ IS-IS Flexible Algorithm provides the routing mechanism: each flex-algo defines - **Controller feature config** — A YAML file loaded by the controller at startup that gates flex-algo topology config, link tagging, and color community stamping independently. Controls the staged rollout of flex-algo to the network without requiring onchain transactions. - **EOS color value** — An integer assigned to a flex-algo definition in EOS (`color ` under `flex-algo`). Causes EOS to install that algorithm's computed tunnels in `system-colored-tunnel-rib` keyed by (endpoint, color). Derived as `admin_group_bit + 1`; not stored separately. - **Flex-algo** — IS-IS Flexible Algorithm (RFC 9350). Each algorithm defines a constrained topology (metric type + admin-group include/exclude rules) and computes an independent SPF. Nodes with the same flex-algo compute consistent paths across the topology. Arista EOS supports flex-algo numbers 128–255. -- **Link color** — A DZF-defined label assigned to a link that maps to an IS-IS TE admin-group. Determines which flex-algo topologies include or exclude the link. -- **Link color constraint** — Each `LinkColorInfo` defines either an `IncludeAny` or `Exclude` constraint. `IncludeAny`: only links explicitly tagged with this color participate in the topology. `Exclude`: all links except those tagged with this color participate. UNICAST-DEFAULT uses `IncludeAny`. +- **Link topology** — A DZF-defined constrained IS-IS forwarding plane assigned to a link via an admin-group. Determines which flex-algo topologies include or exclude the link. +- **Topology constraint** — Each `TopologyInfo` defines either an `IncludeAny` or `Exclude` constraint. `IncludeAny`: only links explicitly tagged with this topology participate. `Exclude`: all links except those tagged with this topology participate. UNICAST-DEFAULT uses `IncludeAny`. - **system-colored-tunnel-rib** — An EOS system RIB auto-populated when flex-algo definitions carry a `color` field. Keyed by (endpoint, color). Used by BGP next-hop resolution to steer VPN routes onto constrained topologies based on the BGP color extended community carried on the route. -- **UNICAST-DEFAULT** — The reserved default color. MUST be the first color created by DZF and MUST be assigned admin-group bit 0, flex-algo 128, and EOS color value 1. These values are protocol invariants — the controller resolves the default tenant color by looking up the `LinkColorInfo` where `admin_group_bit == 0`, not by creation order. Applied to all links eligible for the default unicast topology. Flex-algo 128 uses `include-any UNICAST-DEFAULT`, so only explicitly tagged links participate in the unicast topology. Untagged links are excluded from unicast but remain available to multicast via IS-IS algo 0. +- **Topology vs color** — In this RFC, *topology* refers to a DZF-defined constrained IS-IS forwarding plane (a `TopologyInfo` account). *Color* refers to the EOS/BGP mechanism used to steer traffic onto a topology: the `color` field in an EOS flex-algo definition, the `EOS color value` derived as `admin_group_bit + 1`, and the BGP color extended community (`Color:CO(00):`) stamped on VPN routes. Every DZF topology has a corresponding EOS color, but the two concepts are distinct. +- **UNICAST-DEFAULT** — The reserved default topology. MUST be the first topology created by DZF and MUST be assigned admin-group bit 0, flex-algo 128, and EOS color value 1. These values are protocol invariants — the controller resolves the default tenant topology by looking up the `TopologyInfo` where `admin_group_bit == 0`, not by creation order. Applied to all links eligible for the default unicast topology. Flex-algo 128 uses `include-any UNICAST-DEFAULT`, so only explicitly tagged links participate in the unicast topology. Untagged links are excluded from unicast but remain available to multicast via IS-IS algo 0. --- @@ -50,11 +51,11 @@ IS-IS Flexible Algorithm provides the routing mechanism: each flex-algo defines | Scenario | This RFC | Notes | |---|---|---| -| Default unicast topology via UNICAST-DEFAULT color | ✅ | Core deliverable; all unicast-eligible links must be explicitly tagged | +| Default unicast topology via UNICAST-DEFAULT | ✅ | Core deliverable; all unicast-eligible links must be explicitly tagged | | Multicast uses all links (algo 0) | ✅ | Natural PIM RPF behavior; includes both tagged and untagged links; no config required | -| Multiple links with the same color | ✅ | All tagged links participate together in the constrained topology | +| Multiple links in the same topology | ✅ | All tagged links participate together in the constrained topology | | New links excluded from unicast by default | ✅ | `include-any` strictly excludes untagged links — verified in lab. New links must be explicitly tagged before they carry unicast traffic | -| Per-tenant unicast path differentiation | ✅ | Architecture proven in lab (BGP color extended communities + `system-colored-tunnel-rib`). All unicast tenants receive color 1 (UNICAST-DEFAULT) by default; `include_topology_colors` overrides this with specific colors to steer onto a designated topology | +| Per-tenant unicast path differentiation | ✅ | Architecture proven in lab (BGP color extended communities + `system-colored-tunnel-rib`). All unicast tenants receive color 1 (UNICAST-DEFAULT) by default; `include_topologies` overrides this to steer onto specific topologies | | Exclude a link from multicast | ❌ | PIM RPF uses IS-IS algo 0 unconditionally. No EOS mechanism can redirect multicast away from specific links within the current architecture | | Automated link selection by bandwidth or type | ❌ | Link tagging is manual DZF policy at this stage. `link.bandwidth` and `link.link_type` exist onchain and can drive automated selection in a future RFC | @@ -78,21 +79,21 @@ Three mechanisms are deferred. Each addresses a distinct escalation in steering | Mechanism | Granularity | Solves | Complexity | Trigger to adopt | |---|---|---|---|---| -| CBF with non-default VRFs | Per-tenant VRF, per-DSCP | Different constrained topology per tenant with DSCP-based sub-steering | Medium — TCAM profile change; builds on flex-algo colors defined here | First tenant requiring DSCP-level path differentiation within a VRF | -| SR-TE | Per-prefix or per-flow | Explicit path control with segment lists; per-prefix or per-DSCP steering independent of IGP topology | High — controller must compute or define explicit segment lists per policy, and set BGP Color Extended Community on routes per-tenant | Per-prefix SLA requirements, or when per-tenant flex-algo color is insufficient | +| CBF with non-default VRFs | Per-tenant VRF, per-DSCP | Different constrained topology per tenant with DSCP-based sub-steering | Medium — TCAM profile change; builds on flex-algo topologies defined here | First tenant requiring DSCP-level path differentiation within a VRF | +| SR-TE | Per-prefix or per-flow | Explicit path control with segment lists; per-prefix or per-DSCP steering independent of IGP topology | High — controller must compute or define explicit segment lists per policy, and set BGP Color Extended Community on routes per-tenant | Per-prefix SLA requirements, or when per-tenant flex-algo topology is insufficient | | RSVP-TE | Per-LSP (P2P unicast) or per-tree (P2MP multicast) | Hard bandwidth reservation with admission control | High — RSVP-TE on all path devices, IS-IS TE bandwidth advertisement, controller logic to provision per-tenant tunnel interfaces | SLA-backed bandwidth guarantees where admission control is required, not just path preference | -An `exclude_topology_colors: Vec` field on `Tenant` is a natural extension of the `include_topology_colors` model defined here — it would allow a tenant to explicitly avoid certain topologies. This is deferred; no network infrastructure changes are required to add it when needed. +An `exclude_topologies: Vec` field on `Tenant` is a natural extension of the `include_topologies` model defined here — it would allow a tenant to explicitly avoid certain topologies. This is deferred; no network infrastructure changes are required to add it when needed. --- ## Detailed Design -### Link Color Model +### Link Topology Model -#### LinkColorInfo account +#### TopologyInfo account -DZF creates a `LinkColorInfo` PDA per color. It stores the color's name and auto-assigned routing parameters. The program MUST auto-assign the lowest available admin-group bit from the `AdminGroupBits` `ResourceExtension` account, and derive the corresponding flex-algo number and EOS color value using the formula: +DZF creates a `TopologyInfo` PDA per topology. It stores the topology name and auto-assigned routing parameters. The program MUST auto-assign the lowest available admin-group bit from the `AdminGroupBits` `ResourceExtension` account, and derive the corresponding flex-algo number and EOS color value using the formula: ``` admin_group_bit = next available bit from AdminGroupBits ResourceExtension (0–127) @@ -102,43 +103,45 @@ eos_color_value = admin_group_bit + 1 (derived, not stored) This formula ensures the admin-group bit, flex-algo number, and EOS color value are always in the EOS-supported ranges (bits 0–127, algos 128–255, color 1–4294967295) and are derived consistently from each other. The EOS color value is not stored onchain — it is computed by the controller wherever needed. -The `AdminGroupBits` `ResourceExtension` is a persistent bitmap on `GlobalState` that tracks allocated admin-group bits across the lifetime of the program, including bits from deleted colors. This ensures bits are never reused after deletion — reusing a bit before all devices have had their config updated would cause those devices to apply the new color's constraints to interfaces still carrying the old bit's admin-group. The bitmap survives PDA deletion, which a PDA-scan approach cannot guarantee. +The `AdminGroupBits` `ResourceExtension` is a persistent bitmap on `GlobalState` that tracks allocated admin-group bits across the lifetime of the program, including bits from deleted topologies. This ensures bits are never reused after deletion — reusing a bit before all devices have had their config updated would cause those devices to apply the new topology's constraints to interfaces still carrying the old bit's admin-group. The bitmap survives PDA deletion, which a PDA-scan approach cannot guarantee. ```rust #[derive(BorshSerialize, BorshDeserialize, Debug)] -pub enum LinkColorConstraint { +pub enum TopologyConstraint { IncludeAny = 0, // only tagged links participate in the topology Exclude = 1, // all links except tagged participate in the topology } #[derive(BorshSerialize, BorshDeserialize, Debug)] -pub struct LinkColorInfo { +pub struct TopologyInfo { pub name: String, // e.g. "unicast-default" pub admin_group_bit: u8, // auto-assigned from AdminGroupBits ResourceExtension, 0–127 pub flex_algo_number: u8, // auto-assigned, 128–255; always 128 + admin_group_bit - pub constraint: LinkColorConstraint, // IncludeAny or Exclude + pub constraint: TopologyConstraint, // IncludeAny or Exclude } ``` -PDA seeds: `[b"link-color-info", name.as_bytes()]`. `LinkColorInfo` accounts MUST only be created or updated by foundation keys. +PDA seeds: `[b"topology", name.as_bytes()]`. `TopologyInfo` accounts MUST only be created or updated by foundation keys. Name length MUST NOT exceed 32 bytes, enforced by the program on `create`. This keeps PDA seeds well within the 32-byte limit and ensures the admin-group alias name is reasonable in EOS config. The program MUST validate `admin_group_bit <= 127` on `create` and MUST return an explicit error if all 128 slots are exhausted. This is a hard constraint: EOS supports bits 0–127 only, and `128 + 127 = 255` is the maximum representable value in `flex_algo_number: u8`. -#### link_colors field on Link +#### link_topologies field on Link -A `link_colors: Vec` field is added to the serviceability `Link` account, capped at 8 entries. Each entry holds the pubkey of a `LinkColorInfo` PDA. An empty vector indicates no color is assigned. The field appends to the end of the serialized layout, defaulting to an empty vector on existing accounts. +A `link_topologies: Vec` field is added to the serviceability `Link` account, capped at 8 entries. Each entry holds the pubkey of a `TopologyInfo` PDA. The field appends to the end of the serialized layout, defaulting to an empty vector on existing accounts. -The cap of 8 exists to keep the `Link` account size deterministic on-chain. Only the first entry (`link_colors[0]`) is used by the controller in this RFC — multiple entries are reserved for future multi-color-per-link support (e.g., a link participating in both UNICAST-DEFAULT and SHELBY topologies simultaneously, as validated in lab testing). +The cap of 8 exists to keep the `Link` account size deterministic on-chain. Only the first entry (`link_topologies[0]`) is used by the controller in this RFC — multiple entries are reserved for future multi-topology-per-link support (e.g., a link participating in both UNICAST-DEFAULT and SHELBY topologies simultaneously, as validated in lab testing). -`link_colors` MUST only be set by keys in the DZF foundation allowlist. Contributors MUST NOT set this field. Link tagging is a DZF policy decision — there is no automated selection based on `link.bandwidth` or `link.link_type` at this stage. +**Auto-tagging at activation:** when DZF activates a link, the activation processor MUST automatically set `link_topologies[0]` to the UNICAST-DEFAULT `TopologyInfo` pubkey (resolved by PDA seeds `[b"topology", b"unicast-default"]`). This preserves the existing contributor workflow — a link that passes DZF validation carries unicast traffic without any additional manual step. Foundation keys may subsequently override `link_topologies` to assign a different topology or remove the default tag for specialized links (e.g. multicast-only). + +`link_topologies` overrides MUST only be made by keys in the DZF foundation allowlist. Contributors MUST NOT set this field directly. ```rust // Foundation-only fields if globalstate.foundation_allowlist.contains(payer_account.key) { - if let Some(link_colors) = value.link_colors { - link.link_colors = link_colors; + if let Some(link_topologies) = value.link_topologies { + link.link_topologies = link_topologies; } } ``` @@ -148,18 +151,18 @@ if globalstate.foundation_allowlist.contains(payer_account.key) { **Color lifecycle:** ``` -doublezero link color create --name --constraint -doublezero link color update --name -doublezero link color delete --name -doublezero link color clear --name -doublezero link color list +doublezero link topology create --name --constraint +doublezero link topology update --name +doublezero link topology delete --name +doublezero link topology clear --name +doublezero link topology list ``` -- `create` — creates a `LinkColorInfo` PDA; allocates the lowest available admin-group bit from the `AdminGroupBits` `ResourceExtension`; derives and stores flex-algo number; stores the specified constraint (`include-any` or `exclude`). MUST fail if the name already exists. The first color created MUST be named `unicast-default` and will be allocated bit 0 — this is a protocol invariant and the program MUST enforce it by rejecting any `create` instruction where the `AdminGroupBits` bitmap is empty and the name is not `unicast-default`. Device impact is controlled entirely by the controller feature config — no device config is generated until `flex_algo.enabled: true` is set in the config file. +- `create` — creates a `TopologyInfo` PDA; allocates the lowest available admin-group bit from the `AdminGroupBits` `ResourceExtension`; derives and stores flex-algo number; stores the specified constraint (`include-any` or `exclude`). MUST fail if the name already exists. The first topology created MUST be named `unicast-default` and will be allocated bit 0 — this is a protocol invariant and the program MUST enforce it by rejecting any `create` instruction where the `AdminGroupBits` bitmap is empty and the name is not `unicast-default`. Device impact is controlled entirely by the controller feature config — no device config is generated until `flex_algo.enabled: true` is set in the config file. - `update` — reserved for future use; all fields are immutable after creation. No device config change. -- `delete` — removes the `LinkColorInfo` PDA onchain. MUST fail if any link still references this color (use `clear` first). On the next reconciliation cycle, the controller removes the deleted color's admin-group alias and flex-algo definition from all devices. Admin-group bits from deleted colors MUST NOT be reused — the `AdminGroupBits` `ResourceExtension` bitmap persists allocated bits permanently. -- `clear` — removes this color from all links currently assigned to it, setting `link_colors` to an empty vector on each. This is a multi-transaction sweep — one `LinkUpdateArgs` instruction is submitted per assigned link; it is not atomic. If the sweep fails partway through, the operator MUST re-run `clear`; the operation is idempotent and will only submit instructions for links that still reference the color. The `delete` guard (which rejects if any link still references the color) is the safety net — partial completion is safe because a re-run will clear the remaining references before deletion is attempted. On the next reconciliation cycle, the controller re-applies only the remaining colors on each affected interface — if other colors remain, `traffic-engineering administrative-group ` is applied; if no colors remain, `no traffic-engineering administrative-group` is applied. -- `list` — fetches all `LinkColorInfo` accounts and all `Link` accounts and groups links by color. SHOULD emit a warning if any color has fewer links tagged than the minimum required for a connected topology. +- `delete` — removes the `TopologyInfo` PDA onchain. MUST fail if any link still references this topology (use `clear` first). On the next reconciliation cycle, the controller removes the deleted topology's admin-group alias and flex-algo definition from all devices. Admin-group bits from deleted topologies MUST NOT be reused — the `AdminGroupBits` `ResourceExtension` bitmap persists allocated bits permanently. +- `clear` — removes this topology from all links currently assigned to it, setting `link_topologies` to an empty vector on each. This is a multi-transaction sweep — one `LinkUpdateArgs` instruction is submitted per assigned link; it is not atomic. If the sweep fails partway through, the operator MUST re-run `clear`; the operation is idempotent and will only submit instructions for links that still reference the topology. The `delete` guard (which rejects if any link still references the topology) is the safety net — partial completion is safe because a re-run will clear the remaining references before deletion is attempted. On the next reconciliation cycle, the controller re-applies only the remaining topologies on each affected interface — if other topologies remain, `traffic-engineering administrative-group ` is applied; if no topologies remain, `no traffic-engineering administrative-group` is applied. +- `list` — fetches all `TopologyInfo` accounts and all `Link` accounts and groups links by topology. SHOULD emit a warning if any topology has fewer links tagged than the minimum required for a connected topology. ``` NAME CONSTRAINT FLEX-ALGO ADMIN-GROUP BIT EOS COLOR LINKS @@ -167,44 +170,44 @@ default — — — — unicast-default include-any 128 0 1 link-xyz789 ``` -**Link color assignment:** +**Link topology assignment:** ``` -doublezero link update --pubkey --link-color -doublezero link update --pubkey --link-color default -doublezero link update --code --link-color +doublezero link update --pubkey --link-topology +doublezero link update --pubkey --link-topology default +doublezero link update --code --link-topology ``` -- `--link-color ` MUST resolve the color name to the corresponding `LinkColorInfo` PDA pubkey before submitting the instruction — the onchain field stores pubkeys, not names. Sets `link_colors[0]`. -- `--link-color default` sets `link_colors` to an empty vector, removing any color assignment. -- `doublezero link get` and `doublezero link list` MUST include `link_colors` in their output, showing the resolved color names (or "default"). +- `--link-topology ` MUST resolve the topology name to the corresponding `TopologyInfo` PDA pubkey before submitting the instruction — the onchain field stores pubkeys, not names. Sets `link_topologies[0]`. +- `--link-topology default` sets `link_topologies` to an empty vector, removing any topology assignment. Use with caution — an untagged link will not participate in the unicast topology. +- `doublezero link get` and `doublezero link list` MUST include `link_topologies` in their output, showing the resolved topology names (or "default"). A link activated after UNICAST-DEFAULT is created will immediately display `link-topology: unicast-default` — no additional operator action is required. -#### Tenant topology color assignment +#### Tenant topology assignment -An `include_topology_colors: Vec` field is added to the serviceability `Tenant` account. Each entry holds the pubkey of a `LinkColorInfo` PDA. All unicast tenants receive color 1 (UNICAST-DEFAULT) by default; setting `include_topology_colors` overrides this to assign specific colors based on business requirements. The field appends to the end of the serialized layout, defaulting to an empty vector on existing accounts. +An `include_topologies: Vec` field is added to the serviceability `Tenant` account. Each entry holds the pubkey of a `TopologyInfo` PDA. All unicast tenants receive color 1 (UNICAST-DEFAULT) by default; setting `include_topologies` overrides this to assign specific topologies based on business requirements. The field appends to the end of the serialized layout, defaulting to an empty vector on existing accounts. ```rust #[derive(BorshSerialize, BorshDeserialize, Debug)] pub struct Tenant { // ... existing fields ... - pub include_topology_colors: Vec, // appended; defaults to [] + pub include_topologies: Vec, // appended; defaults to [] } ``` -`include_topology_colors` MUST only be set by foundation keys. This is a routing policy decision — contributors MUST NOT be able to steer their own traffic onto a different topology by modifying this field. +`include_topologies` MUST only be set by foundation keys. This is a routing policy decision — contributors MUST NOT be able to steer their own traffic onto a different topology by modifying this field. -When a tenant has one entry in `include_topology_colors`, the controller resolves the `LinkColorInfo` PDA and stamps its EOS color value on inbound routes for that tenant. When a tenant has multiple entries, the controller stamps all corresponding color values — EOS then selects the best available colored tunnel by IGP metric (lowest metric wins; highest color number breaks ties). This enables a fallback chain: if the preferred topology's tunnel becomes unavailable, EOS automatically falls back to the next-best color on the same prefix without the route going unresolved. This behavior has been verified in lab testing. +When a tenant has one entry in `include_topologies`, the controller resolves the `TopologyInfo` PDA and stamps its EOS color value on inbound routes for that tenant. When a tenant has multiple entries, the controller stamps all corresponding color values — EOS then selects the best available colored tunnel by IGP metric (lowest metric wins; highest color number breaks ties). This enables a fallback chain: if the preferred topology's tunnel becomes unavailable, EOS automatically falls back to the next-best color on the same prefix without the route going unresolved. This behavior has been verified in lab testing. **CLI:** ``` -doublezero tenant update --code --include-topology-colors [,] -doublezero tenant update --code --include-topology-colors default +doublezero tenant update --code --include-topologies [,] +doublezero tenant update --code --include-topologies default ``` -- `--include-topology-colors [,]` resolves each color name to the corresponding `LinkColorInfo` PDA pubkey before submitting the instruction. -- `--include-topology-colors default` sets `include_topology_colors` to an empty vector, reverting the tenant to the default color 1 (UNICAST-DEFAULT). -- `doublezero tenant get` and `doublezero tenant list` MUST display `include_topology_colors` showing resolved color names (or "default"). +- `--include-topologies [,]` resolves each topology name to the corresponding `TopologyInfo` PDA pubkey before submitting the instruction. +- `--include-topologies default` sets `include_topologies` to an empty vector, reverting the tenant to the default color 1 (UNICAST-DEFAULT). +- `doublezero tenant get` and `doublezero tenant list` MUST display `include_topologies` showing resolved topology names (or "default"). --- @@ -239,9 +242,9 @@ features: **Rollout sequence:** -The config file decouples onchain state preparation from device deployment. DZF can create colors and tag links before any device receives flex-algo config, then enable features progressively: +The config file decouples onchain state preparation from device deployment. DZF can create topologies and tag links before any device receives flex-algo config, then enable features progressively: -1. DZF creates `LinkColorInfo` accounts and assigns `link_colors` on links — no device impact while `enabled: false` +1. DZF creates `TopologyInfo` accounts; links activated after this point are automatically tagged `UNICAST-DEFAULT` — no device impact while `enabled: false` 2. Set `enabled: true` and restart the controller — topology config is pushed to all devices on the next reconciliation cycle. No admin-group tagging or community stamping yet 3. Verify all devices show correct flex-algo state (`show isis flex-algo`, `show tunnel rib system-colored-tunnel-rib brief`) 4. Add specific links to the tagging config or leave the exclude list empty to tag all onchain-assigned links — restart controller @@ -249,7 +252,7 @@ The config file decouples onchain state preparation from device deployment. DZF **Precedence for community stamping:** a device is stamped if `all: true`, OR its pubkey is in `devices`, OR the tenant's pubkey is in `tenants` — unless the device's pubkey is in `exclude.devices`, which overrides all positive rules. -**Asymmetric routing:** if community stamping is enabled on some devices but not others, routes entering the network at unstamped devices will carry no color community and resolve via `system-connected` fallback. This is expected behaviour during a phased rollout, not an error condition. +**Asymmetric routing:** if community stamping is enabled on some devices but not others, routes entering the network at unstamped devices will carry no color community. These routes fall through to `tunnel-rib system-tunnel-rib` and resolve via IS-IS SR (algo-0) tunnels. This is expected behaviour during a phased rollout, not an error condition. **Revert behaviour:** when `enabled` is set to `false` and the controller is restarted, the controller generates the full set of `no` commands to remove all flex-algo config from all devices on the next reconciliation cycle: `no router traffic-engineering`, `no flex-algo` definitions, `no next-hop resolution ribs`, and removal of `set extcommunity color` from all route-maps. @@ -259,13 +262,13 @@ The config file decouples onchain state preparation from device deployment. DZF ### IS-IS Flex-Algo Topology -Each link color maps to an IS-IS TE admin-group bit via the `LinkColorInfo` account. The controller MUST read `link.link_colors[0]`, resolve the `LinkColorInfo` PDA, and apply the corresponding admin-group to the physical interface — unless the link's pubkey is in `link_tagging.exclude.links`. +Each link topology maps to an IS-IS TE admin-group bit via the `TopologyInfo` account. The controller MUST read `link.link_topologies[0]`, resolve the `TopologyInfo` PDA, and apply the corresponding admin-group to the physical interface — unless the link's pubkey is in `link_tagging.exclude.links`. -| Link color | Constraint | Admin-group bit | Flex-algo number | EOS color value | Topology | +| Topology | Constraint | Admin-group bit | Flex-algo number | EOS color value | Forwarding scope | |---|---|---|---|---|---| | (untagged) | — | — | — (algo 0) | — | All links | | unicast-default | include-any | 0 | 128 | 1 | Only UNICAST-DEFAULT tagged links | -| (future color) | include-any or exclude | 1 | 129 | 2 | Defined by constraint | +| (future topology) | include-any or exclude | 1 | 129 | 2 | Defined by constraint | The flex-algo definition MUST be configured on each DZD by the controller. The `color` field MUST be included and set to `admin_group_bit + 1`. The constraint type determines whether `include any` or `exclude` is used. Using UNICAST-DEFAULT as an example: @@ -280,15 +283,15 @@ router traffic-engineering Flex-algo 128 ("unicast-default") computes an IS-IS SPF over only those links tagged `UNICAST-DEFAULT`. The `color 1` field causes EOS to install these tunnels in `system-colored-tunnel-rib` keyed by (endpoint, 1). Devices that participate in flex-algo 128 advertise both an algo-0 node-segment and an algo-128 node-segment via their loopback. -**Operational implication:** Every link intended to carry unicast traffic MUST be explicitly tagged `UNICAST-DEFAULT`. Links added to the network are excluded from the unicast topology by default until DZF assigns the color. The `link color list` command SHOULD warn if a color's topology appears disconnected based on the set of tagged links. +**Operational implication:** Links are automatically tagged `UNICAST-DEFAULT` at activation — no manual DZF step is required for the common case. The contributor workflow is unchanged: a link that passes DZF validation immediately participates in the unicast topology. The `link topology list` command SHOULD warn if a topology appears disconnected based on the set of tagged links, and SHOULD warn if any activated links have an empty `link_topologies` (indicating a link activated before this RFC that has not been migrated). #### Universal participation requirement -Flex-algo MUST be enabled on every device in the network, not only on devices that have colored links. A device that does not participate in a flex-algo does not advertise a node-SID for that algorithm, so other devices cannot include it in the constrained SPF and cannot steer VPN traffic to it via the constrained topology. VPN routes to a non-participating device will not resolve via the colored tunnel RIB and will fall back to the next resolution source. The controller MUST therefore push the flex-algo definitions and BGP next-hop resolution config to all devices when `enabled: true`. Admin-group tagging on interfaces is applied only to links with a non-empty `link_colors` that are not in the `link_tagging.exclude.links` list. +Flex-algo MUST be enabled on every device in the network, not only on devices that have colored links. A device that does not participate in a flex-algo does not advertise a node-SID for that algorithm, so other devices cannot include it in the constrained SPF and cannot steer VPN traffic to it via the constrained topology. VPN routes to a non-participating device will not resolve via the colored tunnel RIB and will fall back to the next resolution source. The controller MUST therefore push the flex-algo definitions and BGP next-hop resolution config to all devices when `enabled: true`. Admin-group tagging on interfaces is applied only to links with a non-empty `link_topologies` that are not in the `link_tagging.exclude.links` list. #### Multicast path isolation -Multicast (PIM) resolves via the IS-IS unicast RIB (algo 0), which uses all links regardless of color. This is inherent to how PIM RPF works — it is not affected by the BGP next-hop resolution profile, and `next-hop resolution ribs` does not support multicast address families. Multicast isolation does not depend on any additional configuration — PIM RPF resolves via the unicast RIB regardless of how VPN unicast is steered. +Multicast (PIM) resolves via the IS-IS unicast RIB (algo 0), which uses all links regardless of topology assignment. This is inherent to how PIM RPF works — it is not affected by the BGP next-hop resolution profile, and `next-hop resolution ribs` does not support multicast address families. Multicast isolation does not depend on any additional configuration — PIM RPF resolves via the unicast RIB regardless of how VPN unicast is steered. | Service | Path | Links in path | |---|---|---| @@ -308,10 +311,10 @@ All devices MUST be configured with the following BGP next-hop resolution profil ``` router bgp 65342 address-family vpn-ipv4 - next-hop resolution ribs tunnel-rib colored system-colored-tunnel-rib system-connected + next-hop resolution ribs tunnel-rib colored system-colored-tunnel-rib tunnel-rib system-tunnel-rib ``` -`system-colored-tunnel-rib` is auto-populated by EOS when flex-algo definitions carry a `color` field. A VPN route carrying `Color:CO(00):1` resolves its next-hop through the color-1 (unicast-default, algo 128) tunnel to that endpoint. +`system-colored-tunnel-rib` is auto-populated by EOS when flex-algo definitions carry a `color` field. A VPN route carrying `Color:CO(00):1` resolves its next-hop through the color-1 (unicast-default, algo 128) tunnel to that endpoint. Routes without a color community fall through to `tunnel-rib system-tunnel-rib`, which is auto-populated by IS-IS SR (algo-0) tunnels. `system-connected` is deliberately omitted — this ensures all VPN traffic uses MPLS forwarding (either colored flex-algo or algo-0 SR) and never falls back to plain IP. Verified in lab testing: with only `tunnel-rib colored system-colored-tunnel-rib` configured, uncolored VPN routes are received but their next-hops cannot be resolved and they never make it into the VRF routing table. #### Inbound route-map color stamping @@ -322,14 +325,14 @@ route-map RM-USER-{{ .Id }}-IN permit 10 match ip address prefix-list PL-USER-{{ .Id }} match as-path length = 1 set community 21682:{{ if eq true .IsMulticast }}1300{{ else }}1200{{ end }} 21682:{{ $.Device.BgpCommunity }} - {{- if and $.Config.FlexAlgo.Enabled (not .IsMulticast) $.LinkColors ($.Config.FlexAlgo.CommunityStamping.ShouldStamp .TenantPubKey $.Device.PubKey) }} + {{- if and $.Config.FlexAlgo.Enabled (not .IsMulticast) $.LinkTopologies ($.Config.FlexAlgo.CommunityStamping.ShouldStamp .TenantPubKey $.Device.PubKey) }} set extcommunity color {{ .TenantTopologyEosColorValues }} {{- end }} ``` `.TenantTopologyEosColorValues` is resolved by the controller from the tunnel's tenant: -- If `tenant.include_topology_colors` is non-empty, resolve each `LinkColorInfo` PDA and compute `AdminGroupBit + 1` for each. All resolved color values are stamped in a single `set extcommunity color` statement (e.g., `set extcommunity color 1 color 2`). -- If `tenant.include_topology_colors` is empty, use the default unicast color: resolve the `LinkColorInfo` where `admin_group_bit == 0` (UNICAST-DEFAULT, EOS color value 1). +- If `tenant.include_topologies` is non-empty, resolve each `TopologyInfo` PDA and compute `AdminGroupBit + 1` for each. All resolved color values are stamped in a single `set extcommunity color` statement (e.g., `set extcommunity color 1 color 2`). +- If `tenant.include_topologies` is empty, use the default unicast color: resolve the `TopologyInfo` where `admin_group_bit == 0` (UNICAST-DEFAULT, EOS color value 1). When multiple colors are stamped, EOS selects the colored tunnel with the lowest IGP metric to the next-hop. If two colors tie on metric, the highest color number wins. If a preferred color's tunnel becomes unavailable (e.g., the destination withdraws its node-segment for that algorithm), EOS automatically falls back to the next-best available color — the route remains installed throughout with no disruption. This fallback behavior has been verified in lab testing. @@ -350,35 +353,35 @@ Inside the existing `{{- range .Device.Interfaces }}` block, after the `isis met ``` {{- if and .Ip.IsValid .IsPhysical .Metric .IsLink (not .IsSubInterfaceParent) (not .IsCYOA) (not .IsDIA) }} traffic-engineering - {{- if and .LinkColors (not ($.Config.FlexAlgo.LinkTagging.IsExcluded .PubKey)) }} - traffic-engineering administrative-group {{ $.Strings.Join " " ($.Strings.ToUpperEach .LinkColorNames) }} + {{- if and .LinkTopologies (not ($.Config.FlexAlgo.LinkTagging.IsExcluded .PubKey)) }} + traffic-engineering administrative-group {{ $.Strings.Join " " ($.Strings.ToUpperEach .LinkTopologyNames) }} {{- else }} no traffic-engineering administrative-group {{- end }} {{- end }} ``` -`.LinkColors` is the resolved list of `LinkColorInfo` accounts from `link.link_colors`; it is empty when `link_colors` is empty. `.LinkColorNames` is the corresponding list of names. The controller renders all colors as a space-separated list in a single command — EOS overwrites the existing admin-group assignment with exactly this set. This means: -- A link transitioning from two colors to one re-applies only the surviving color, atomically replacing the previous set -- A link losing its last color receives `no traffic-engineering administrative-group` +`.LinkTopologies` is the resolved list of `TopologyInfo` accounts from `link.link_topologies`; it is empty when `link_topologies` is empty. `.LinkTopologyNames` is the corresponding list of names. The controller renders all topologies as admin-group names in a space-separated list in a single command — EOS overwrites the existing admin-group assignment with exactly this set. This means: +- A link transitioning from two topologies to one re-applies only the surviving topology, atomically replacing the previous set +- A link losing its last topology receives `no traffic-engineering administrative-group` - The targeted `no traffic-engineering administrative-group ` command is never used, avoiding the EOS behavior where it would remove all groups regardless of the name specified -Interface-level admin-group tagging is conditioned on `$.Config.FlexAlgo.Enabled` alone — since an interface may have colors assigned onchain while the feature is disabled. +Interface-level admin-group tagging is conditioned on `$.Config.FlexAlgo.Enabled` alone — since an interface may have topologies assigned onchain while the feature is disabled. #### 2. router traffic-engineering block -Add after the `router isis 1` block, conditional on colors being defined and the feature being enabled: +Add after the `router isis 1` block, conditional on topologies being defined and the feature being enabled: ``` -{{- if and $.Config.FlexAlgo.Enabled .LinkColors }} +{{- if and $.Config.FlexAlgo.Enabled .LinkTopologies }} router traffic-engineering router-id ipv4 {{ .Device.Vpn4vLoopbackIP }} - {{- range .LinkColors }} + {{- range .LinkTopologies }} administrative-group alias {{ $.Strings.ToUpper .Name }} group {{ .AdminGroupBit }} {{- end }} ! flex-algo - {{- range .LinkColors }} + {{- range .LinkTopologies }} flex-algo {{ .FlexAlgoNumber }} {{ .Name }} {{- if eq .Constraint "include-any" }} administrative-group include any {{ .AdminGroupBit }} @@ -390,7 +393,7 @@ router traffic-engineering {{- end }} ``` -`.LinkColors` is the ordered list of `LinkColorInfo` accounts, sorted by `AdminGroupBit`. `.EosColorValue` is computed as `AdminGroupBit + 1`. The flex-algo name (e.g., `unicast-default`) is the color name stored in `LinkColorInfo`. +`.LinkTopologies` is the ordered list of `TopologyInfo` accounts, sorted by `AdminGroupBit`. `.EosColorValue` is computed as `AdminGroupBit + 1`. The flex-algo name (e.g., `unicast-default`) is the topology name stored in `TopologyInfo`. When `$.Config.FlexAlgo.Enabled` is false, the controller generates `no router traffic-engineering` to remove any previously-pushed config. @@ -408,20 +411,20 @@ Inside the existing `address-family vpn-ipv4` block: {{- range .UnknownBgpPeers }} no neighbor {{ . }} {{- end }} - {{- if and $.Config.FlexAlgo.Enabled .LinkColors }} - next-hop resolution ribs tunnel-rib colored system-colored-tunnel-rib system-connected + {{- if and $.Config.FlexAlgo.Enabled .LinkTopologies }} + next-hop resolution ribs tunnel-rib colored system-colored-tunnel-rib tunnel-rib system-tunnel-rib {{- end }} ! ``` #### 4. IS-IS flex-algo advertisement and traffic-engineering -Inside the existing `router isis 1` block, under `segment-routing mpls`, add a `flex-algo` advertisement line per color: +Inside the existing `router isis 1` block, under `segment-routing mpls`, add a `flex-algo` advertisement line per topology: ``` segment-routing mpls no shutdown - {{- range .LinkColors }} + {{- range .LinkTopologies }} flex-algo {{ .Name }} level-2 advertised {{- end }} ``` @@ -429,7 +432,7 @@ Inside the existing `router isis 1` block, under `segment-routing mpls`, add a ` After the `segment-routing mpls` block, add the `traffic-engineering` section: ``` -{{- if and $.Config.FlexAlgo.Enabled .LinkColors }} +{{- if and $.Config.FlexAlgo.Enabled .LinkTopologies }} traffic-engineering no shutdown is-type level-2 @@ -445,29 +448,44 @@ Inside the existing `{{- range .Device.Interfaces }}` block, extend the existing ``` {{- if and .IsVpnv4Loopback .NodeSegmentIdx }} node-segment ipv4 index {{ .NodeSegmentIdx }} - {{- range $.LinkColors }} - {{- if .FlexAlgoNodeSegmentIdx }} - node-segment ipv4 index {{ .FlexAlgoNodeSegmentIdx }} flex-algo {{ .Name }} - {{- end }} + {{- range .FlexAlgoNodeSegments }} + node-segment ipv4 index {{ .NodeSegmentIdx }} flex-algo {{ .TopologyName }} {{- end }} {{- end }} ``` -The flex-algo node-segment index follows the same pattern as the existing algo-0 `node_segment_idx`: it is allocated from the `SegmentRoutingIds` `ResourceExtension` account at interface activation time (in `processors/device/interface/activate.rs`) and stored on the `Interface` account onchain. A new `flex_algo_node_segment_idx: u16` field MUST be added to the `Interface` account and allocated alongside `node_segment_idx` when the interface's loopback type is `Vpnv4`. It is deallocated on `remove`, following the existing pattern. +Each Vpnv4 loopback must advertise one node-SID per flex-algo topology it participates in. Because node-SIDs must be globally unique per device, each (interface, topology) pair needs its own allocated index. A new `flex_algo_node_segments: Vec` field MUST be added to the `Interface` account: + +```rust +pub struct FlexAlgoNodeSegment { + pub topology: Pubkey, // TopologyInfo PDA pubkey + pub node_segment_idx: u16, // allocated from SegmentRoutingIds ResourceExtension +} +pub flex_algo_node_segments: Vec, +``` + +At interface activation time, one `FlexAlgoNodeSegment` is allocated per known `TopologyInfo` account and appended to the list. Each `node_segment_idx` is allocated from the `SegmentRoutingIds` `ResourceExtension` account, following the same pattern as the existing `node_segment_idx`. Entries are deallocated on `remove`. -**Migration for existing interfaces:** Vpn4v loopback interfaces that were activated before this RFC will not have `flex_algo_node_segment_idx` allocated. Existing `node_segment_idx` assignments (algo-0, used today) are unchanged — this migration is purely additive. A one-time `doublezero-admin` CLI migration command MUST be provided to iterate all existing `Interface` accounts with `loopback_type = Vpnv4`, allocate a `flex_algo_node_segment_idx` for each, and persist the updated account. Loopbacks activated after this RFC will have `flex_algo_node_segment_idx` allocated at activation time alongside `node_segment_idx`. +In the template, `.FlexAlgoNodeSegments` is accessed directly on the current interface (the `.` context within `{{- range .Device.Interfaces }}`). The controller populates this from the interface's onchain `flex_algo_node_segments` list during rendering, resolving the topology name from each entry's `TopologyInfo` pubkey. This is intentionally distinct from `.LinkTopologies` (which describes which topologies a specific link is tagged with) — the loopback template is concerned with which topologies this device participates in, not with link tagging. -The controller MUST check at startup, before enabling flex-algo, that no Vpn4v loopback has `flex_algo_node_segment_idx == 0`. If any unset loopbacks are found, the controller MUST refuse to apply flex-algo config and emit an error directing the operator to run the migration command. This prevents silently pushing a broken topology where some devices are unreachable via the constrained path. +**Migration for existing accounts:** A one-time `doublezero-admin` CLI migration command MUST be provided covering two tasks: + +1. **Links** — iterate all existing `Link` accounts with `link_topologies = []` and set `link_topologies[0]` to the UNICAST-DEFAULT `TopologyInfo` pubkey. Links activated after this RFC are auto-tagged at activation; this migration covers links activated before the RFC was deployed. +2. **Vpnv4 loopbacks** — iterate all existing `Interface` accounts with `loopback_type = Vpnv4` and allocate a `FlexAlgoNodeSegment` entry for each known `TopologyInfo` account. Existing `node_segment_idx` assignments (algo-0) are unchanged — this is purely additive. Loopbacks activated after this RFC will have entries allocated at activation time. + +The migration command MUST be idempotent — re-running it MUST skip already-migrated accounts and only process those still requiring migration. It MUST emit a summary on completion (e.g. `migrated 12 links, 4 loopbacks; skipped 3 already migrated`). A `--dry-run` flag MUST be supported to preview the accounts that would be migrated without applying any changes. + +The controller MUST check at startup that no Vpnv4 loopback has an empty `flex_algo_node_segments` list when `flex_algo.enabled: true` is set. If any unset loopbacks are found, `enabled: true` MUST be treated as a no-op for that startup cycle — the controller MUST NOT push any flex-algo config to any device, MUST emit a prominent error identifying the unset loopbacks by pubkey, and MUST direct the operator to run the migration command and restart. The `features.yaml` flag is not modified. Flex-algo config will not be applied until the migration is complete and the controller is restarted cleanly. This prevents silently pushing a broken topology where some devices are unreachable via the constrained path. Without a flex-algo node-SID on the loopback, remote devices cannot compute a valid constrained path to this device and VPN routes to it will not resolve via the colored tunnel RIB. -All blocks are conditional on `.LinkColors` being non-empty, so devices with no colors defined produce identical config to today. +Interface admin-group blocks are conditional on `.LinkTopologies` being non-empty. The flex-algo node-segment lines within the loopback block are conditional on `.FlexAlgoNodeSegments` being non-empty — if the list is empty, the `range` loop produces no output and only the algo-0 `node-segment` line is rendered. Devices with no topologies defined produce identical config to today. --- ### SDK Changes -`LinkColorInfo` MUST be added to the Go, Python, and TypeScript SDKs. The `link` deserialization structs MUST include the new `link_colors: Vec` field. The `tenant` deserialization structs MUST include the new `include_topology_colors: Vec` field. Fixture files MUST be regenerated. +`TopologyInfo` MUST be added to the Go, Python, and TypeScript SDKs. The `link` deserialization structs MUST include the new `link_topologies: Vec` field. The `tenant` deserialization structs MUST include the new `include_topologies: Vec` field. Fixture files MUST be regenerated. --- @@ -475,73 +493,74 @@ All blocks are conditional on `.LinkColors` being non-empty, so devices with no #### Smart contract (integration tests) -**LinkColorInfo lifecycle:** -- A foundation key MUST be able to create a `LinkColorInfo` account with a name; admin-group bit MUST be allocated from the `AdminGroupBits` `ResourceExtension` starting at 0, and flex-algo number MUST be 128. -- Creating a second color MUST allocate bit 1 from the `ResourceExtension` and flex-algo 129. -- A non-foundation key MUST NOT be able to create a `LinkColorInfo` account; the instruction MUST be rejected with an authorization error. -- All `LinkColorInfo` fields are immutable after creation; an `update` instruction MUST be rejected or be a no-op. -- A non-foundation key MUST NOT be able to update a `LinkColorInfo` account. -- `delete` MUST succeed when no links reference the color; the `LinkColorInfo` PDA MUST be removed onchain. -- `delete` MUST fail when one or more links still reference the color. -- After `clear`, all links previously assigned the color MUST have `link_colors = []`. -- After `clear` followed by `delete`, the `LinkColorInfo` PDA MUST be absent. -- Admin-group bits from deleted colors MUST NOT be reused by subsequently created colors; the `AdminGroupBits` `ResourceExtension` bitmap MUST persist the allocated bit after PDA deletion. -- After `delete`, the controller MUST NOT generate removal commands for the deleted color's admin-group alias, flex-algo definition, or IS-IS TE config — device-side cleanup is deferred. - -**Tenant topology color assignment:** -- `include_topology_colors` MUST default to an empty vector on a newly created tenant account and on existing accounts deserialized from pre-upgrade binary data. -- A foundation key MUST be able to set `include_topology_colors` to a list of valid `LinkColorInfo` pubkeys on any tenant. -- A non-foundation key MUST NOT be able to set `include_topology_colors`; the instruction MUST be rejected with an authorization error. -- Setting `include_topology_colors` to an empty vector MUST be accepted and revert the tenant to the default color 1 (UNICAST-DEFAULT). -- Setting `include_topology_colors` to a pubkey that does not correspond to a valid `LinkColorInfo` account MUST be rejected. - -**Link color assignment:** -- `link_colors` MUST default to an empty vector on a newly created link account and on existing accounts deserialized from pre-upgrade binary data. -- A foundation key MUST be able to set `link_colors[0]` to a valid `LinkColorInfo` pubkey on any link. -- A contributor key MUST NOT be able to set `link_colors`; the instruction MUST be rejected with an authorization error. -- Setting `link_colors` to an empty vector from a non-empty value MUST be accepted and persist correctly. -- Setting `link_colors[0]` to a pubkey that does not correspond to a valid `LinkColorInfo` account MUST be rejected. -- `link_colors` MUST NOT exceed 8 entries; an instruction submitting more than 8 MUST be rejected. +**TopologyInfo lifecycle:** +- A foundation key MUST be able to create a `TopologyInfo` account with a name; admin-group bit MUST be allocated from the `AdminGroupBits` `ResourceExtension` starting at 0, and flex-algo number MUST be 128. +- Creating a second topology MUST allocate bit 1 from the `ResourceExtension` and flex-algo 129. +- A non-foundation key MUST NOT be able to create a `TopologyInfo` account; the instruction MUST be rejected with an authorization error. +- All `TopologyInfo` fields are immutable after creation; an `update` instruction MUST be rejected or be a no-op. +- A non-foundation key MUST NOT be able to update a `TopologyInfo` account. +- `delete` MUST succeed when no links reference the topology; the `TopologyInfo` PDA MUST be removed onchain. +- `delete` MUST fail when one or more links still reference the topology. +- After `clear`, all links previously assigned the topology MUST have `link_topologies = []`. +- After `clear` followed by `delete`, the `TopologyInfo` PDA MUST be absent. +- Admin-group bits from deleted topologies MUST NOT be reused by subsequently created topologies; the `AdminGroupBits` `ResourceExtension` bitmap MUST persist the allocated bit after PDA deletion. +- After `delete`, the controller MUST NOT generate removal commands for the deleted topology's admin-group alias, flex-algo definition, or IS-IS TE config — device-side cleanup is deferred. + +**Tenant topology assignment:** +- `include_topologies` MUST default to an empty vector on a newly created tenant account and on existing accounts deserialized from pre-upgrade binary data. +- A foundation key MUST be able to set `include_topologies` to a list of valid `TopologyInfo` pubkeys on any tenant. +- A non-foundation key MUST NOT be able to set `include_topologies`; the instruction MUST be rejected with an authorization error. +- Setting `include_topologies` to an empty vector MUST be accepted and revert the tenant to the default color 1 (UNICAST-DEFAULT). +- Setting `include_topologies` to a pubkey that does not correspond to a valid `TopologyInfo` account MUST be rejected. + +**Link topology assignment:** +- `link_topologies` MUST default to an empty vector on existing accounts deserialized from pre-upgrade binary data. +- The activation processor MUST set `link_topologies[0]` to the UNICAST-DEFAULT `TopologyInfo` pubkey on every newly activated link. If the UNICAST-DEFAULT `TopologyInfo` account does not exist at activation time, the instruction MUST fail. +- A foundation key MUST be able to override `link_topologies[0]` to a valid `TopologyInfo` pubkey on any link. +- A contributor key MUST NOT be able to set `link_topologies`; the instruction MUST be rejected with an authorization error. +- Setting `link_topologies` to an empty vector from a non-empty value MUST be accepted and persist correctly. +- Setting `link_topologies[0]` to a pubkey that does not correspond to a valid `TopologyInfo` account MUST be rejected. +- `link_topologies` MUST NOT exceed 8 entries; an instruction submitting more than 8 MUST be rejected. #### Controller (unit tests) -- A link with `link_colors = []` MUST produce interface config with no `traffic-engineering administrative-group` line. -- A link with `link_colors[0]` referencing a `LinkColorInfo` with bit 0, name "unicast-default", and constraint `IncludeAny` MUST produce interface config with `traffic-engineering administrative-group UNICAST-DEFAULT`. -- A link in `link_tagging.exclude.links` MUST produce `no traffic-engineering administrative-group` regardless of onchain `link_colors` assignment. -- Transitioning a link from a color to default MUST produce a `no traffic-engineering administrative-group` diff. -- Transitioning a link from one color to another MUST produce the correct remove/add diff. +- A link with `link_topologies = []` MUST produce interface config with no `traffic-engineering administrative-group` line. +- A link with `link_topologies[0]` referencing a `TopologyInfo` with bit 0, name "unicast-default", and constraint `IncludeAny` MUST produce interface config with `traffic-engineering administrative-group UNICAST-DEFAULT`. +- A link in `link_tagging.exclude.links` MUST produce `no traffic-engineering administrative-group` regardless of onchain `link_topologies` assignment. +- Transitioning a link from a topology to default MUST produce a `no traffic-engineering administrative-group` diff. +- Transitioning a link from one topology to another MUST produce the correct remove/add diff. - The `router traffic-engineering` block MUST include `color ` on each flex-algo definition. -- The BGP `next-hop resolution ribs tunnel-rib colored system-colored-tunnel-rib system-connected` config MUST be generated correctly when `enabled: true`. -- A per-tunnel inbound route-map MUST include `set extcommunity color 1` for a unicast tenant with empty `include_topology_colors` when the tenant or device is in the `community_stamping` config and `LinkColors` is non-empty. -- A per-tunnel inbound route-map MUST include `set extcommunity color 1 color 2` for a unicast tenant with `include_topology_colors` referencing two `LinkColorInfo` accounts (bits 0 and 1). +- The BGP `next-hop resolution ribs tunnel-rib colored system-colored-tunnel-rib tunnel-rib system-tunnel-rib` config MUST be generated correctly when `enabled: true`. +- A per-tunnel inbound route-map MUST include `set extcommunity color 1` for a unicast tenant with empty `include_topologies` when the tenant or device is in the `community_stamping` config and `.LinkTopologies` is non-empty. +- A per-tunnel inbound route-map MUST include `set extcommunity color 1 color 2` for a unicast tenant with `include_topologies` referencing two `TopologyInfo` accounts (bits 0 and 1). - A per-tunnel inbound route-map MUST NOT include `set extcommunity color` when the device is in `community_stamping.exclude.devices`. -- A new `LinkColorInfo` account detected on reconciliation MUST cause the controller to push updated config to all devices. -- **Config disabled:** With `LinkColorInfo` accounts defined, links tagged, and `enabled: false`, the controller MUST generate `no router traffic-engineering`, no flex-algo IS-IS config, no `next-hop resolution ribs` line, and no `set extcommunity color` in any route-map. Device config MUST be identical to a network with no colors defined. -- **Config enabled:** Setting `enabled: true` with existing `LinkColorInfo` accounts and tagged links MUST cause the controller to generate the full flex-algo config block on the next reconciliation cycle. +- A new `TopologyInfo` account detected on reconciliation MUST cause the controller to push updated config to all devices. +- **Config disabled:** With `TopologyInfo` accounts defined, links tagged, and `enabled: false`, the controller MUST generate `no router traffic-engineering`, no flex-algo IS-IS config, no `next-hop resolution ribs` line, and no `set extcommunity color` in any route-map. Device config MUST be identical to a network with no topologies defined. +- **Config enabled:** Setting `enabled: true` with existing `TopologyInfo` accounts and tagged links MUST cause the controller to generate the full flex-algo config block on the next reconciliation cycle. - **Interface tagging independent of stamping:** Interface-level `traffic-engineering administrative-group` config MUST be generated based on `$.Config.FlexAlgo.Enabled` alone, regardless of `community_stamping` settings. #### SDK (unit tests) -- `LinkColorInfo` account MUST serialize and deserialize correctly via Borsh for all fields. -- `LinkColorInfo` account MUST deserialize correctly from a binary fixture. -- `link_colors` pubkey vector MUST be included in `link get` and `link list` output in all three SDKs, showing the color names (resolved from `LinkColorInfo`) or "default". +- `TopologyInfo` account MUST serialize and deserialize correctly via Borsh for all fields. +- `TopologyInfo` account MUST deserialize correctly from a binary fixture. +- `link_topologies` pubkey vector MUST be included in `link get` and `link list` output in all three SDKs, showing the topology names (resolved from `TopologyInfo`) or "default". - The `list` command MUST display the derived EOS color value (`admin_group_bit + 1`) in output. #### End-to-end (cEOS testcontainers) -- **Color creation**: After a foundation key creates a `LinkColorInfo` for "unicast-default" (bit 0, flex-algo 128, constraint include-any) and `enabled: true` is set, the controller MUST push `router traffic-engineering` config with `administrative-group alias UNICAST-DEFAULT group 0`, `flex-algo 128 unicast-default administrative-group include any 0 color 1`, and the BGP `next-hop resolution ribs` line to all devices. -- **Admin-group application**: After a foundation key sets `link_colors[0]` on a link to the unicast-default `LinkColorInfo` pubkey, `show traffic-engineering database` on the connected devices MUST reflect `UNICAST-DEFAULT` admin-group on the interface. Clearing the color MUST remove the admin-group. -- **Link tagging exclude**: A link in `link_tagging.exclude.links` MUST NOT have an admin-group applied even when `link_colors[0]` is set onchain. +- **Topology creation**: After a foundation key creates a `TopologyInfo` for "unicast-default" (bit 0, flex-algo 128, constraint include-any) and `enabled: true` is set, the controller MUST push `router traffic-engineering` config with `administrative-group alias UNICAST-DEFAULT group 0`, `flex-algo 128 unicast-default administrative-group include any 0 color 1`, and the BGP `next-hop resolution ribs` line to all devices. +- **Admin-group application**: After a foundation key sets `link_topologies[0]` on a link to the unicast-default `TopologyInfo` pubkey, `show traffic-engineering database` on the connected devices MUST reflect `UNICAST-DEFAULT` admin-group on the interface. Clearing the topology MUST remove the admin-group. +- **Link tagging exclude**: A link in `link_tagging.exclude.links` MUST NOT have an admin-group applied even when `link_topologies[0]` is set onchain. - **Flex-algo topology**: With links tagged UNICAST-DEFAULT, `show isis flex-algo` on participating devices MUST show algo 128 including only UNICAST-DEFAULT links. Untagged links MUST be absent from the algo-128 LSDB view. - **Colored tunnel RIB**: `show tunnel rib system-colored-tunnel-rib brief` MUST show (endpoint, color 1) entries for each participating device, resolving via unicast-default tunnels. - **VPN unicast path selection**: A BGP VPN-IPv4 route carrying `Color:CO(00):1` MUST resolve its next-hop through the color-1 (unicast-default) tunnel in `system-colored-tunnel-rib`, traversing only UNICAST-DEFAULT tagged links. -- **Per-tenant color — single**: A tenant with `include_topology_colors = [SHELBY pubkey]` MUST have `Color:CO(00):2` stamped on its inbound routes. A tenant with empty `include_topology_colors` (default) MUST have `Color:CO(00):1` (UNICAST-DEFAULT). -- **Per-tenant color — multi**: A tenant with `include_topology_colors = [UNICAST-DEFAULT pubkey, SHELBY pubkey]` MUST have both `Color:CO(00):1` and `Color:CO(00):2` stamped. `show ip route vrf ` MUST show the lower-metric color tunnel selected for next-hop resolution. -- **Per-tenant color — fallback**: Removing a device's node-segment for the preferred color algorithm MUST cause EOS to fall back to the next available color on the same prefix without the route going unresolved. +- **Per-tenant topology — single**: A tenant with `include_topologies = [SHELBY pubkey]` MUST have `Color:CO(00):2` stamped on its inbound routes. A tenant with empty `include_topologies` (default) MUST have `Color:CO(00):1` (UNICAST-DEFAULT). +- **Per-tenant topology — multi**: A tenant with `include_topologies = [UNICAST-DEFAULT pubkey, SHELBY pubkey]` MUST have both `Color:CO(00):1` and `Color:CO(00):2` stamped. `show ip route vrf ` MUST show the lower-metric color tunnel selected for next-hop resolution. +- **Per-tenant topology — fallback**: Removing a device's node-segment for the preferred topology's algorithm MUST cause EOS to fall back to the next available color on the same prefix without the route going unresolved. - **Community stamping — per device**: A tenant on a device in `community_stamping.devices` MUST have the color community on its inbound routes. The same tenant on a device NOT in the config MUST NOT have the color community. - **Community stamping — exclude**: A device in `community_stamping.exclude.devices` MUST NOT have `set extcommunity color` applied regardless of `all` or `tenants` settings. - **Multicast path isolation**: PIM RPF for a multicast source MUST continue to resolve via IS-IS algo 0 (all links, including both tagged and untagged) regardless of BGP next-hop resolution config. -- **Color clear**: After `link color clear --name unicast-default` removes the color from all links, the controller MUST generate `no traffic-engineering administrative-group UNICAST-DEFAULT` on all previously-tagged interfaces on the next reconciliation cycle. +- **Topology clear**: After `link topology clear --name unicast-default` removes the topology from all links, the controller MUST generate `no traffic-engineering administrative-group UNICAST-DEFAULT` on all previously-tagged interfaces on the next reconciliation cycle. - **Revert**: Setting `enabled: false` and restarting the controller MUST result in all flex-algo config being removed from all devices on the next reconciliation cycle. #### EOS Verification @@ -566,13 +585,13 @@ MUST confirm (endpoint, color 1) entries are present for each participating devi ``` show isis segment-routing prefix-segments ``` -MUST confirm each participating device advertises both an algo-0 index and a flex-algo index for each defined color. +MUST confirm each participating device advertises both an algo-0 index and a flex-algo index for each defined topology. **Verify BGP next-hop resolution binding is active:** ``` show bgp instance ``` -MUST confirm `address-family IPv4 MplsVpn` shows `Resolution RIBs: tunnel-rib colored system-colored-tunnel-rib, system-connected`. +MUST confirm `address-family IPv4 MplsVpn` shows `Resolution RIBs: tunnel-rib colored system-colored-tunnel-rib, tunnel-rib system-tunnel-rib`. **Verify VPN route color community and resolution:** ``` @@ -586,7 +605,7 @@ MUST confirm BGP VPN-IPv4 routes carry `Color:CO(00):1` and resolve next-hops th show traffic-engineering database show traffic-engineering interfaces ``` -MUST confirm admin-group membership is visible only on interfaces with a non-empty `link_colors`. +MUST confirm admin-group membership is visible only on interfaces with a non-empty `link_topologies`. **Verify multicast RPF uses algo-0 (including colored links):** ``` @@ -600,41 +619,51 @@ MUST confirm PIM RPF resolves via the IS-IS unicast RIB (algo 0). The incoming i ### Codebase -- **serviceability** — new `LinkColorInfo` PDA (foundation-managed, one per color); new `AdminGroupBits` `ResourceExtension` account for persistent bit allocation; new `link_colors: Vec` field (cap 8) on `Link`; new `link_colors: Option>` field on `LinkUpdateArgs` with foundation-only write restriction; new `flex_algo_node_segment_idx: u16` field on `Interface`; new `include_topology_colors: Vec` field on `Tenant` with foundation-only write restriction. -- **controller** — new `-features-config` flag and `features.yaml` config file; reads `link.link_colors[0]`, resolves `LinkColorInfo` PDAs, generates IS-IS TE admin-group config on interfaces (respecting `link_tagging.exclude.links`), flex-algo definitions with `color` field, `system-colored-tunnel-rib` BGP resolution profile, and adds `set extcommunity color` to the existing per-tunnel inbound route-maps (`RM-USER-{{ .Id }}-IN`) for stamping-eligible tunnels; generates `no` commands for full revert when `enabled: false`. -- **CLI** — full color lifecycle commands (`create`, `update`, `delete`, `clear`, `list`); `link update` gains `--link-color`; `link get` / `link list` display the field including derived EOS color value; `link color list` warns on disconnected topologies. -- **SDKs** — `LinkColorInfo` added to all three language SDKs; `link_colors` field added to link deserialization structs. +- **serviceability** — new `TopologyInfo` PDA (foundation-managed, one per topology); new `AdminGroupBits` `ResourceExtension` account for persistent bit allocation; new `link_topologies: Vec` field (cap 8) on `Link`; new `link_topologies: Option>` field on `LinkUpdateArgs` with foundation-only write restriction; new `flex_algo_node_segments: Vec` field on `Interface` (one entry per `TopologyInfo` topology, each with its own allocated node segment index); new `include_topologies: Vec` field on `Tenant` with foundation-only write restriction. +- **controller** — new `-features-config` flag and `features.yaml` config file; reads `link.link_topologies[0]`, resolves `TopologyInfo` PDAs, generates IS-IS TE admin-group config on interfaces (respecting `link_tagging.exclude.links`), flex-algo definitions with `color` field, `system-colored-tunnel-rib` BGP resolution profile, and adds `set extcommunity color` to the existing per-tunnel inbound route-maps (`RM-USER-{{ .Id }}-IN`) for stamping-eligible tunnels; generates `no` commands for full revert when `enabled: false`. +- **CLI** — full topology lifecycle commands (`create`, `update`, `delete`, `clear`, `list`); `link update` gains `--link-topology`; `link get` / `link list` display the field including derived EOS color value; `link topology list` warns on disconnected topologies. +- **SDKs** — `TopologyInfo` added to all three language SDKs; `link_topologies` field added to link deserialization structs. ### Operational -- DZF MUST create a `LinkColorInfo` account and assign `link_colors` on links before the controller applies TE admin-groups. Until a color is created and assigned, links behave as today. -- Adding a new color MUST NOT require a code change or deploy — DZF creates the `LinkColorInfo` account via the CLI and the controller picks it up on the next reconciliation cycle once `enabled: true`. -- `link_colors` appends to the serialized layout and defaults to an empty vector on existing accounts. No migration is required. +**Deployment procedure:** + +The following sequence MUST be followed when deploying this RFC to any environment: + +1. Deploy the smart contract code update +2. Immediately create the UNICAST-DEFAULT topology via CLI: `doublezero link topology create --name unicast-default --constraint include-any` — this MUST happen before any new link activations are accepted. Link activation MUST fail with an explicit error (`"UNICAST-DEFAULT topology not found"`) if this step is skipped +3. Run the migration command to tag existing links and allocate loopback node segments for pre-existing accounts +4. Resume normal link activation workflow — new links will be auto-tagged at activation from this point + +Attempting to activate a link between steps 1 and 2 will fail with a clear error. There is no silent partial state. + +- Adding a new topology MUST NOT require a code change or deploy — DZF creates the `TopologyInfo` account via the CLI and the controller picks it up on the next reconciliation cycle once `enabled: true`. +- `link_topologies` appends to the serialized layout and defaults to an empty vector on existing accounts. Existing links activated before this RFC will have `link_topologies = []` and MUST be tagged with UNICAST-DEFAULT as part of the testnet rollout migration before `enabled: true` is set. - The transition from no-color to color-1 on all tenant VRFs is a one-time controller config push. The template section order enforces the correct sequencing within a single reconciliation cycle: the `router traffic-engineering` block and `address-family vpn-ipv4 next-hop resolution` config appear before the `route-map RM-USER-*-IN` blocks in `tunnel.tmpl`, so EOS applies them top-to-bottom in the correct order. Applying the route-map before the RIB is configured would cause VPN routes to go unresolved. ### Testing | Layer | Tests | |---|---| -| Smart contract | `LinkColorInfo` create/update/delete lifecycle; `AdminGroupBits` `ResourceExtension` allocation and no-reuse after deletion; foundation-only authorization; `link_colors` assignment and clearing; delete blocked when links assigned; `clear` removes color from all links; `link_colors` cap at 8 enforced; `include_topology_colors` assignment on tenant; foundation-only authorization | -| Controller (unit) | Interface config with and without color; link tagging exclude list respected; remove/add diff on color change; `router traffic-engineering` block with `color` field; `system-colored-tunnel-rib` BGP resolution config; single-color and multi-color per-tenant stamping; community stamping per-tenant and per-device; stamping exclude respected; full revert on `enabled: false`; new color triggers config push to all devices | -| SDK (unit) | `LinkColorInfo` Borsh round-trip; `link_colors` field in `link get` / `link list` output; `include_topology_colors` field in `tenant get` / `tenant list` output across Go, Python, and TypeScript; EOS color value displayed correctly | -| End-to-end (cEOS) | Color create → controller config push verified; admin-group applied and removed on interface; link tagging exclude list verified; flex-algo 128 topology includes only UNICAST-DEFAULT links; `system-colored-tunnel-rib` populated; VPN unicast resolves via color-1 tunnel; per-tenant single and multi-color stamping verified; color fallback verified; community stamping per-device verified; stamping exclude verified; multicast RPF unchanged; color clear removes admin-groups; full revert on `enabled: false` verified | +| Smart contract | `TopologyInfo` create/update/delete lifecycle; `AdminGroupBits` `ResourceExtension` allocation and no-reuse after deletion; foundation-only authorization; `link_topologies` assignment and clearing; delete blocked when links assigned; `clear` removes topology from all links; `link_topologies` cap at 8 enforced; `include_topologies` assignment on tenant; foundation-only authorization | +| Controller (unit) | Interface config with and without topology; link tagging exclude list respected; remove/add diff on topology change; `router traffic-engineering` block with `color` field; `system-colored-tunnel-rib` BGP resolution config; single-topology and multi-topology per-tenant stamping; community stamping per-tenant and per-device; stamping exclude respected; full revert on `enabled: false`; new topology triggers config push to all devices | +| SDK (unit) | `TopologyInfo` Borsh round-trip; `link_topologies` field in `link get` / `link list` output; `include_topologies` field in `tenant get` / `tenant list` output across Go, Python, and TypeScript; EOS color value displayed correctly | +| End-to-end (cEOS) | Topology create → controller config push verified; admin-group applied and removed on interface; link tagging exclude list verified; flex-algo 128 topology includes only UNICAST-DEFAULT links; `system-colored-tunnel-rib` populated; VPN unicast resolves via color-1 tunnel; per-tenant single and multi-topology stamping verified; topology fallback verified; community stamping per-device verified; stamping exclude verified; multicast RPF unchanged; topology clear removes admin-groups; full revert on `enabled: false` verified | --- ## Security Considerations -- `link_colors` MUST only be writable by foundation keys. A contributor MUST NOT be able to tag their own link with a color to influence path steering. The check mirrors the existing pattern used for `link.status` foundation-override. -- `LinkColorInfo` accounts MUST only be created, updated, or deleted by foundation keys. +- `link_topologies` MUST only be writable by foundation keys. A contributor MUST NOT be able to tag their own link with a topology to influence path steering. The check mirrors the existing pattern used for `link.status` foundation-override. +- `TopologyInfo` accounts MUST only be created, updated, or deleted by foundation keys. - The controller feature config file (`features.yaml`) is a local file on the controller host. Access to this file SHOULD be restricted to the operator running the controller. An attacker with write access to the config file could enable or disable flex-algo config or manipulate the stamping allowlist without an onchain transaction. -- If a foundation key is compromised, an attacker could reclassify links or create new color definitions, causing traffic to be steered onto unintended paths. This is the same threat surface as other foundation-key-controlled fields. No new mitigations are introduced. +- If a foundation key is compromised, an attacker could reclassify links or create new topology definitions, causing traffic to be steered onto unintended paths. This is the same threat surface as other foundation-key-controlled fields. No new mitigations are introduced. --- ## Backward Compatibility -- `link_colors` appends to the serialized layout and defaults to an empty vector on existing accounts. No migration is required for the onchain schema. +- `link_topologies` appends to the serialized layout and defaults to an empty vector on existing accounts. The onchain schema requires no migration; existing links MUST be tagged with UNICAST-DEFAULT via the `doublezero-admin` migration command as part of the rollout. - Devices that do not receive updated config from the controller MUST continue to forward using IS-IS algo 0 only. The flex-algo topology is distributed — a device that does not participate is simply not included in the constrained SPF. - The controller MUST push `system-colored-tunnel-rib` config and flex-algo definitions before activating per-VRF route-maps. Activating route-maps first causes VPN routes to carry a color community with no matching tunnel RIB entry, leaving them unresolved. The template section order enforces this within a single reconciliation cycle. - The BGP next-hop resolution profile is applied globally. Per-tenant resolution profile overrides are not supported at this stage. @@ -643,20 +672,20 @@ MUST confirm PIM RPF resolves via the IS-IS unicast RIB (algo 0). The incoming i ## Observability -Introducing multiple forwarding topologies over the same physical links has implications for the network visualization tools in the lake repository. Today, lake displays a single IS-IS topology where every link is treated equally. With flex-algo, a link's effective participation depends on its color and the traffic type being considered — an untagged link is present in the algo-0 view but absent from the algo-128 (unicast-default) view used by VPN unicast. +Introducing multiple forwarding topologies over the same physical links has implications for the network visualization tools in the lake repository. Today, lake displays a single IS-IS topology where every link is treated equally. With flex-algo, a link's effective participation depends on its topology assignment and the traffic type being considered — an untagged link is present in the algo-0 view but absent from the algo-128 (unicast-default) view used by VPN unicast. Areas that SHOULD evolve: -- **Topology view filtering** — the topology map SHOULD allow operators to switch between views: all links (algo 0), unicast topology (algo 128), or a per-service overlay. A link's color SHOULD be visually distinct and its inclusion or exclusion from each topology SHOULD be clear. -- **Link color display** — link color and admin-group membership SHOULD be surfaced on the topology map alongside existing link attributes (latency, capacity). This gives operators immediate visibility into how a link is classified without needing to query the CLI. +- **Topology view filtering** — the topology map SHOULD allow operators to switch between views: all links (algo 0), unicast topology (algo 128), or a per-service overlay. A link's topology assignment SHOULD be visually distinct and its inclusion or exclusion from each forwarding plane SHOULD be clear. +- **Link topology display** — link topology and admin-group membership SHOULD be surfaced on the topology map alongside existing link attributes (latency, capacity). This gives operators immediate visibility into how a link is classified without needing to query the CLI. - **Service-aware path visualization** — when tracing a path between two nodes, the tool SHOULD reflect which topology that traffic type actually uses. A unicast path between two nodes may differ from the multicast path over the same physical graph. -- **Tenant / VRF context** — VPN unicast traffic is resolved per VRF. A future multi-VRF deployment with per-tenant colors would require topology views to be scoped to a tenant, showing the paths available within that VRF's forwarding context. +- **Tenant / VRF context** — VPN unicast traffic is resolved per VRF. A future multi-VRF deployment with per-tenant topologies would require topology views to be scoped to a tenant, showing the paths available within that VRF's forwarding context. --- ## Open Questions -- **Color naming convention**: The RFC defines `unicast-default` and `shelby` as the initial colors. Should DZF adopt a consistent naming convention for future colors? Options include: +- **Topology naming convention**: The RFC defines `unicast-default` and `shelby` as the initial topologies. Should DZF adopt a consistent naming convention for future topologies? Options include: - **Functionality** (`unicast-default`, `low-latency`) — self-documenting from an operational perspective, but ties the name to a specific use-case that may evolve. - - **Product/tenant names** (`shelby`, `shreds`) — scoped to a customer or service, which may be appropriate if colors end up being per-tenant rather than network-wide. - The name is stored onchain in `LinkColorInfo` and appears in EOS config (converted to uppercase as the admin-group alias), so it SHOULD be stable once assigned. + - **Product/tenant names** (`shelby`, `shreds`) — scoped to a customer or service, which may be appropriate if topologies end up being per-tenant rather than network-wide. + The name is stored onchain in `TopologyInfo` and appears in EOS config (converted to uppercase as the admin-group alias), so it SHOULD be stable once assigned. From 6aafc4fd2837eaa1335a5ec4e7a9007114743a59 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 30 Mar 2026 21:58:55 -0500 Subject: [PATCH 04/49] =?UTF-8?q?rfc18:=20rename=20eos=5Fcolor=5Fvalue=20?= =?UTF-8?q?=E2=86=92=20color;=20add=20UNICAST-DRAINED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename `eos_color_value` → `color` throughout onchain/controller context (formula variable, flex-algo template `.Color`, table columns, prose). Terminology entry "EOS color value" retained as the defined term for the EOS/BGP mechanism. - Add UNICAST-DRAINED as the second reserved TopologyInfo (bit 1, flex-algo 129, color 2, constraint Exclude). Protocol invariant enforced: second topology create must be named unicast-drained. - Controller template injects `exclude $drainBit` into every include-any flex-algo definition; drain is additive to link_topologies. - Add `doublezero link drain / restore` CLI commands (foundation-only). - Add bootstrapping step 3 (create UNICAST-DRAINED immediately after UNICAST-DEFAULT). - Add smart contract, controller unit, and e2e test requirements for UNICAST-DRAINED invariant, drain/restore lifecycle, and exclude precedence (RFC 9350 §5.2.1). Co-Authored-By: Claude Sonnet 4.6 --- rfcs/rfc18-link-classification-flex-algo.md | 65 ++++++++++++++------- 1 file changed, 45 insertions(+), 20 deletions(-) diff --git a/rfcs/rfc18-link-classification-flex-algo.md b/rfcs/rfc18-link-classification-flex-algo.md index 69df68730d..da58df6d98 100644 --- a/rfcs/rfc18-link-classification-flex-algo.md +++ b/rfcs/rfc18-link-classification-flex-algo.md @@ -9,7 +9,7 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S DoubleZero contributors operate links with different physical characteristics — low latency, high bandwidth, or both. Today all traffic uses the same IS-IS topology, so every service follows the same paths regardless of what those paths are optimized for. This RFC introduces a link classification model that allows DZF to assign named topology labels to links onchain and use IS-IS Flexible Algorithm (flex-algo) to compute separate constraint-based forwarding topologies per label. Different traffic classes — VPN unicast and IP multicast — can then use different topologies. **Deliverables:** -- `TopologyInfo` onchain account — DZF creates this to define a topology, with auto-assigned admin-group bit (from the `AdminGroupBits` `ResourceExtension`), flex-algo number, and derived EOS color value +- `TopologyInfo` onchain account — DZF creates this to define a topology, with auto-assigned admin-group bit (from the `AdminGroupBits` `ResourceExtension`), flex-algo number, and derived color - `link_topologies: Vec` field on the serviceability link account — references assigned topologies; capped at 8 entries; only the first entry is used by the controller in this RFC - Controller feature config file (`features.yaml`) — loaded at startup; gates flex-algo topology config, link admin-group tagging, and BGP color community stamping independently; replaces any onchain feature flag for this capability - Controller logic — translates topologies into IS-IS TE admin-groups on interfaces, generates flex-algo topology definitions, configures `system-colored-tunnel-rib` as the BGP next-hop resolution source, and applies BGP color extended community route-maps per tunnel; all conditioned on the controller config @@ -43,7 +43,8 @@ IS-IS Flexible Algorithm provides the routing mechanism: each flex-algo defines - **Topology constraint** — Each `TopologyInfo` defines either an `IncludeAny` or `Exclude` constraint. `IncludeAny`: only links explicitly tagged with this topology participate. `Exclude`: all links except those tagged with this topology participate. UNICAST-DEFAULT uses `IncludeAny`. - **system-colored-tunnel-rib** — An EOS system RIB auto-populated when flex-algo definitions carry a `color` field. Keyed by (endpoint, color). Used by BGP next-hop resolution to steer VPN routes onto constrained topologies based on the BGP color extended community carried on the route. - **Topology vs color** — In this RFC, *topology* refers to a DZF-defined constrained IS-IS forwarding plane (a `TopologyInfo` account). *Color* refers to the EOS/BGP mechanism used to steer traffic onto a topology: the `color` field in an EOS flex-algo definition, the `EOS color value` derived as `admin_group_bit + 1`, and the BGP color extended community (`Color:CO(00):`) stamped on VPN routes. Every DZF topology has a corresponding EOS color, but the two concepts are distinct. -- **UNICAST-DEFAULT** — The reserved default topology. MUST be the first topology created by DZF and MUST be assigned admin-group bit 0, flex-algo 128, and EOS color value 1. These values are protocol invariants — the controller resolves the default tenant topology by looking up the `TopologyInfo` where `admin_group_bit == 0`, not by creation order. Applied to all links eligible for the default unicast topology. Flex-algo 128 uses `include-any UNICAST-DEFAULT`, so only explicitly tagged links participate in the unicast topology. Untagged links are excluded from unicast but remain available to multicast via IS-IS algo 0. +- **UNICAST-DEFAULT** — The reserved default topology. MUST be the first topology created by DZF and MUST be assigned admin-group bit 0, flex-algo 128, and color 1. These values are protocol invariants — the controller resolves the default tenant topology by looking up the `TopologyInfo` where `admin_group_bit == 0`, not by creation order. Applied to all links eligible for the default unicast topology. Flex-algo 128 uses `include-any UNICAST-DEFAULT`, so only explicitly tagged links participate in the unicast topology. Untagged links are excluded from unicast but remain available to multicast via IS-IS algo 0. +- **UNICAST-DRAINED** — The reserved drain topology. MUST be the second topology created by DZF and MUST be assigned admin-group bit 1, flex-algo 129, and color 2. These values are protocol invariants — the controller resolves the drained topology by looking up the `TopologyInfo` where `admin_group_bit == 1`. Constraint MUST be `Exclude`: only links tagged with UNICAST-DRAINED are excluded from each topology's constrained SPF. Drain is additive — adding UNICAST-DRAINED to `link_topologies` does not remove other topology assignments; the link's permanent tags remain unchanged. The controller injects `exclude {{ $drainBit }}` into every `include-any` flex-algo definition, so a drained link is pruned from all include-any topologies unconditionally (RFC 9350 §5.2.1: `exclude` is evaluated before `include-any` and MUST take precedence). To drain a link, add the UNICAST-DRAINED pubkey to `link_topologies`; to restore, remove it. --- @@ -93,15 +94,15 @@ An `exclude_topologies: Vec` field on `Tenant` is a natural extension of #### TopologyInfo account -DZF creates a `TopologyInfo` PDA per topology. It stores the topology name and auto-assigned routing parameters. The program MUST auto-assign the lowest available admin-group bit from the `AdminGroupBits` `ResourceExtension` account, and derive the corresponding flex-algo number and EOS color value using the formula: +DZF creates a `TopologyInfo` PDA per topology. It stores the topology name and auto-assigned routing parameters. The program MUST auto-assign the lowest available admin-group bit from the `AdminGroupBits` `ResourceExtension` account, and derive the corresponding flex-algo number and color using the formula: ``` admin_group_bit = next available bit from AdminGroupBits ResourceExtension (0–127) flex_algo_number = 128 + admin_group_bit -eos_color_value = admin_group_bit + 1 (derived, not stored) +color = admin_group_bit + 1 (derived, not stored) ``` -This formula ensures the admin-group bit, flex-algo number, and EOS color value are always in the EOS-supported ranges (bits 0–127, algos 128–255, color 1–4294967295) and are derived consistently from each other. The EOS color value is not stored onchain — it is computed by the controller wherever needed. +This formula ensures the admin-group bit, flex-algo number, and color are always in the EOS-supported ranges (bits 0–127, algos 128–255, color 1–4294967295) and are derived consistently from each other. The color is not stored onchain — it is computed by the controller wherever needed. The `AdminGroupBits` `ResourceExtension` is a persistent bitmap on `GlobalState` that tracks allocated admin-group bits across the lifetime of the program, including bits from deleted topologies. This ensures bits are never reused after deletion — reusing a bit before all devices have had their config updated would cause those devices to apply the new topology's constraints to interfaces still carrying the old bit's admin-group. The bitmap survives PDA deletion, which a PDA-scan approach cannot guarantee. @@ -158,14 +159,14 @@ doublezero link topology clear --name doublezero link topology list ``` -- `create` — creates a `TopologyInfo` PDA; allocates the lowest available admin-group bit from the `AdminGroupBits` `ResourceExtension`; derives and stores flex-algo number; stores the specified constraint (`include-any` or `exclude`). MUST fail if the name already exists. The first topology created MUST be named `unicast-default` and will be allocated bit 0 — this is a protocol invariant and the program MUST enforce it by rejecting any `create` instruction where the `AdminGroupBits` bitmap is empty and the name is not `unicast-default`. Device impact is controlled entirely by the controller feature config — no device config is generated until `flex_algo.enabled: true` is set in the config file. +- `create` — creates a `TopologyInfo` PDA; allocates the lowest available admin-group bit from the `AdminGroupBits` `ResourceExtension`; derives and stores flex-algo number; stores the specified constraint (`include-any` or `exclude`). MUST fail if the name already exists. The first topology created MUST be named `unicast-default` and will be allocated bit 0 — this is a protocol invariant and the program MUST enforce it by rejecting any `create` instruction where the `AdminGroupBits` bitmap is empty and the name is not `unicast-default`. The second topology created MUST be named `unicast-drained` and will be allocated bit 1 — this is a protocol invariant and the program MUST enforce it by rejecting any `create` instruction where only bit 0 is allocated and the name is not `unicast-drained`. Device impact is controlled entirely by the controller feature config — no device config is generated until `flex_algo.enabled: true` is set in the config file. - `update` — reserved for future use; all fields are immutable after creation. No device config change. - `delete` — removes the `TopologyInfo` PDA onchain. MUST fail if any link still references this topology (use `clear` first). On the next reconciliation cycle, the controller removes the deleted topology's admin-group alias and flex-algo definition from all devices. Admin-group bits from deleted topologies MUST NOT be reused — the `AdminGroupBits` `ResourceExtension` bitmap persists allocated bits permanently. - `clear` — removes this topology from all links currently assigned to it, setting `link_topologies` to an empty vector on each. This is a multi-transaction sweep — one `LinkUpdateArgs` instruction is submitted per assigned link; it is not atomic. If the sweep fails partway through, the operator MUST re-run `clear`; the operation is idempotent and will only submit instructions for links that still reference the topology. The `delete` guard (which rejects if any link still references the topology) is the safety net — partial completion is safe because a re-run will clear the remaining references before deletion is attempted. On the next reconciliation cycle, the controller re-applies only the remaining topologies on each affected interface — if other topologies remain, `traffic-engineering administrative-group ` is applied; if no topologies remain, `no traffic-engineering administrative-group` is applied. - `list` — fetches all `TopologyInfo` accounts and all `Link` accounts and groups links by topology. SHOULD emit a warning if any topology has fewer links tagged than the minimum required for a connected topology. ``` -NAME CONSTRAINT FLEX-ALGO ADMIN-GROUP BIT EOS COLOR LINKS +NAME CONSTRAINT FLEX-ALGO ADMIN-GROUP BIT COLOR LINKS default — — — — link-abc123, link-def456 unicast-default include-any 128 0 1 link-xyz789 ``` @@ -182,6 +183,19 @@ doublezero link update --code --link-topology - `--link-topology default` sets `link_topologies` to an empty vector, removing any topology assignment. Use with caution — an untagged link will not participate in the unicast topology. - `doublezero link get` and `doublezero link list` MUST include `link_topologies` in their output, showing the resolved topology names (or "default"). A link activated after UNICAST-DEFAULT is created will immediately display `link-topology: unicast-default` — no additional operator action is required. +**Link drain and restore:** + +``` +doublezero link drain --pubkey +doublezero link drain --code +doublezero link restore --pubkey +doublezero link restore --code +``` + +- `drain` appends the UNICAST-DRAINED `TopologyInfo` pubkey to `link_topologies`. The link's existing topology assignments are unchanged. On the next reconciliation cycle, the controller detects the UNICAST-DRAINED entry and EOS applies `exclude ` in each include-any flex-algo definition, pruning the link from all constrained topologies. +- `restore` removes the UNICAST-DRAINED pubkey from `link_topologies`. The link's permanent topology assignments remain in place and are immediately re-eligible in constrained SPF on the next reconciliation cycle. +- Both commands MUST be restricted to foundation keys. `drain` MUST be idempotent — if the link is already drained, it MUST succeed silently. + #### Tenant topology assignment An `include_topologies: Vec` field is added to the serviceability `Tenant` account. Each entry holds the pubkey of a `TopologyInfo` PDA. All unicast tenants receive color 1 (UNICAST-DEFAULT) by default; setting `include_topologies` overrides this to assign specific topologies based on business requirements. The field appends to the end of the serialized layout, defaulting to an empty vector on existing accounts. @@ -196,7 +210,7 @@ pub struct Tenant { `include_topologies` MUST only be set by foundation keys. This is a routing policy decision — contributors MUST NOT be able to steer their own traffic onto a different topology by modifying this field. -When a tenant has one entry in `include_topologies`, the controller resolves the `TopologyInfo` PDA and stamps its EOS color value on inbound routes for that tenant. When a tenant has multiple entries, the controller stamps all corresponding color values — EOS then selects the best available colored tunnel by IGP metric (lowest metric wins; highest color number breaks ties). This enables a fallback chain: if the preferred topology's tunnel becomes unavailable, EOS automatically falls back to the next-best color on the same prefix without the route going unresolved. This behavior has been verified in lab testing. +When a tenant has one entry in `include_topologies`, the controller resolves the `TopologyInfo` PDA and stamps its color on inbound routes for that tenant. When a tenant has multiple entries, the controller stamps all corresponding color values — EOS then selects the best available colored tunnel by IGP metric (lowest metric wins; highest color number breaks ties). This enables a fallback chain: if the preferred topology's tunnel becomes unavailable, EOS automatically falls back to the next-best color on the same prefix without the route going unresolved. This behavior has been verified in lab testing. **CLI:** @@ -264,11 +278,11 @@ The config file decouples onchain state preparation from device deployment. DZF Each link topology maps to an IS-IS TE admin-group bit via the `TopologyInfo` account. The controller MUST read `link.link_topologies[0]`, resolve the `TopologyInfo` PDA, and apply the corresponding admin-group to the physical interface — unless the link's pubkey is in `link_tagging.exclude.links`. -| Topology | Constraint | Admin-group bit | Flex-algo number | EOS color value | Forwarding scope | +| Topology | Constraint | Admin-group bit | Flex-algo number | Color | Forwarding scope | |---|---|---|---|---|---| | (untagged) | — | — | — (algo 0) | — | All links | | unicast-default | include-any | 0 | 128 | 1 | Only UNICAST-DEFAULT tagged links | -| (future topology) | include-any or exclude | 1 | 129 | 2 | Defined by constraint | +| unicast-drained | exclude | 1 | 129 | 2 | All links except UNICAST-DRAINED tagged links | The flex-algo definition MUST be configured on each DZD by the controller. The `color` field MUST be included and set to `admin_group_bit + 1`. The constraint type determines whether `include any` or `exclude` is used. Using UNICAST-DEFAULT as an example: @@ -332,7 +346,7 @@ route-map RM-USER-{{ .Id }}-IN permit 10 `.TenantTopologyEosColorValues` is resolved by the controller from the tunnel's tenant: - If `tenant.include_topologies` is non-empty, resolve each `TopologyInfo` PDA and compute `AdminGroupBit + 1` for each. All resolved color values are stamped in a single `set extcommunity color` statement (e.g., `set extcommunity color 1 color 2`). -- If `tenant.include_topologies` is empty, use the default unicast color: resolve the `TopologyInfo` where `admin_group_bit == 0` (UNICAST-DEFAULT, EOS color value 1). +- If `tenant.include_topologies` is empty, use the default unicast color: resolve the `TopologyInfo` where `admin_group_bit == 0` (UNICAST-DEFAULT, color 1). When multiple colors are stamped, EOS selects the colored tunnel with the lowest IGP metric to the next-hop. If two colors tie on metric, the highest color number wins. If a preferred color's tunnel becomes unavailable (e.g., the destination withdraws its node-segment for that algorithm), EOS automatically falls back to the next-best available color — the route remains installed throughout with no disruption. This fallback behavior has been verified in lab testing. @@ -381,19 +395,20 @@ router traffic-engineering {{- end }} ! flex-algo + {{- $drainBit := $.DrainedAdminGroupBit }} {{- range .LinkTopologies }} flex-algo {{ .FlexAlgoNumber }} {{ .Name }} {{- if eq .Constraint "include-any" }} - administrative-group include any {{ .AdminGroupBit }} + administrative-group include any {{ .AdminGroupBit }} exclude {{ $drainBit }} {{- else }} administrative-group exclude {{ .AdminGroupBit }} {{- end }} - color {{ .EosColorValue }} + color {{ .Color }} {{- end }} {{- end }} ``` -`.LinkTopologies` is the ordered list of `TopologyInfo` accounts, sorted by `AdminGroupBit`. `.EosColorValue` is computed as `AdminGroupBit + 1`. The flex-algo name (e.g., `unicast-default`) is the topology name stored in `TopologyInfo`. +`.LinkTopologies` is the ordered list of `TopologyInfo` accounts, sorted by `AdminGroupBit`. `.Color` is computed as `AdminGroupBit + 1`. `$.DrainedAdminGroupBit` is the `AdminGroupBit` of the UNICAST-DRAINED `TopologyInfo`, resolved by PDA seeds `[b"topology", b"unicast-drained"]`. The flex-algo name (e.g., `unicast-default`) is the topology name stored in `TopologyInfo`. When `$.Config.FlexAlgo.Enabled` is false, the controller generates `no router traffic-engineering` to remove any previously-pushed config. @@ -496,6 +511,8 @@ Interface admin-group blocks are conditional on `.LinkTopologies` being non-empt **TopologyInfo lifecycle:** - A foundation key MUST be able to create a `TopologyInfo` account with a name; admin-group bit MUST be allocated from the `AdminGroupBits` `ResourceExtension` starting at 0, and flex-algo number MUST be 128. - Creating a second topology MUST allocate bit 1 from the `ResourceExtension` and flex-algo 129. +- Creating any topology before `unicast-default` (bitmap empty) with a name other than `unicast-default` MUST be rejected. +- Creating any topology as the second topology (only bit 0 allocated) with a name other than `unicast-drained` MUST be rejected. - A non-foundation key MUST NOT be able to create a `TopologyInfo` account; the instruction MUST be rejected with an authorization error. - All `TopologyInfo` fields are immutable after creation; an `update` instruction MUST be rejected or be a no-op. - A non-foundation key MUST NOT be able to update a `TopologyInfo` account. @@ -530,6 +547,10 @@ Interface admin-group blocks are conditional on `.LinkTopologies` being non-empt - Transitioning a link from a topology to default MUST produce a `no traffic-engineering administrative-group` diff. - Transitioning a link from one topology to another MUST produce the correct remove/add diff. - The `router traffic-engineering` block MUST include `color ` on each flex-algo definition. +- Each `include-any` flex-algo definition MUST include `exclude ` in addition to `include any `. An `exclude`-constraint topology MUST NOT have the drained-bit injected. +- A link with UNICAST-DRAINED in `link_topologies` alongside UNICAST-DEFAULT MUST produce interface config with `traffic-engineering administrative-group UNICAST-DEFAULT UNICAST-DRAINED`. +- Draining a link (adding UNICAST-DRAINED to `link_topologies`) MUST NOT remove other topology assignments from the interface config. +- Restoring a link (removing UNICAST-DRAINED from `link_topologies`) MUST produce interface config identical to a never-drained link with the same permanent topology assignments. - The BGP `next-hop resolution ribs tunnel-rib colored system-colored-tunnel-rib tunnel-rib system-tunnel-rib` config MUST be generated correctly when `enabled: true`. - A per-tunnel inbound route-map MUST include `set extcommunity color 1` for a unicast tenant with empty `include_topologies` when the tenant or device is in the `community_stamping` config and `.LinkTopologies` is non-empty. - A per-tunnel inbound route-map MUST include `set extcommunity color 1 color 2` for a unicast tenant with `include_topologies` referencing two `TopologyInfo` accounts (bits 0 and 1). @@ -544,7 +565,7 @@ Interface admin-group blocks are conditional on `.LinkTopologies` being non-empt - `TopologyInfo` account MUST serialize and deserialize correctly via Borsh for all fields. - `TopologyInfo` account MUST deserialize correctly from a binary fixture. - `link_topologies` pubkey vector MUST be included in `link get` and `link list` output in all three SDKs, showing the topology names (resolved from `TopologyInfo`) or "default". -- The `list` command MUST display the derived EOS color value (`admin_group_bit + 1`) in output. +- The `list` command MUST display the derived color (`admin_group_bit + 1`) in output. #### End-to-end (cEOS testcontainers) @@ -561,6 +582,9 @@ Interface admin-group blocks are conditional on `.LinkTopologies` being non-empt - **Community stamping — exclude**: A device in `community_stamping.exclude.devices` MUST NOT have `set extcommunity color` applied regardless of `all` or `tenants` settings. - **Multicast path isolation**: PIM RPF for a multicast source MUST continue to resolve via IS-IS algo 0 (all links, including both tagged and untagged) regardless of BGP next-hop resolution config. - **Topology clear**: After `link topology clear --name unicast-default` removes the topology from all links, the controller MUST generate `no traffic-engineering administrative-group UNICAST-DEFAULT` on all previously-tagged interfaces on the next reconciliation cycle. +- **Link drain**: After `doublezero link drain` on a UNICAST-DEFAULT tagged link, `show traffic-engineering database` MUST show both `UNICAST-DEFAULT` and `UNICAST-DRAINED` admin-groups on the interface. `show isis flex-algo` MUST show the link absent from the algo-128 (unicast-default) constrained topology. The link MUST remain visible in algo-0. +- **Link restore**: After `doublezero link restore` on a drained link, `show traffic-engineering database` MUST show only the permanent topology tags. `show isis flex-algo` MUST show the link restored to the constrained topology. +- **Drain exclude precedence**: The `exclude UNICAST-DRAINED` constraint in each `include-any` flex-algo definition MUST take precedence over `include any ` — a link tagged with both UNICAST-DEFAULT and UNICAST-DRAINED MUST NOT appear in the algo-128 SPF (verified per RFC 9350 §5.2.1 exclude-before-include evaluation). - **Revert**: Setting `enabled: false` and restarting the controller MUST result in all flex-algo config being removed from all devices on the next reconciliation cycle. #### EOS Verification @@ -621,7 +645,7 @@ MUST confirm PIM RPF resolves via the IS-IS unicast RIB (algo 0). The incoming i - **serviceability** — new `TopologyInfo` PDA (foundation-managed, one per topology); new `AdminGroupBits` `ResourceExtension` account for persistent bit allocation; new `link_topologies: Vec` field (cap 8) on `Link`; new `link_topologies: Option>` field on `LinkUpdateArgs` with foundation-only write restriction; new `flex_algo_node_segments: Vec` field on `Interface` (one entry per `TopologyInfo` topology, each with its own allocated node segment index); new `include_topologies: Vec` field on `Tenant` with foundation-only write restriction. - **controller** — new `-features-config` flag and `features.yaml` config file; reads `link.link_topologies[0]`, resolves `TopologyInfo` PDAs, generates IS-IS TE admin-group config on interfaces (respecting `link_tagging.exclude.links`), flex-algo definitions with `color` field, `system-colored-tunnel-rib` BGP resolution profile, and adds `set extcommunity color` to the existing per-tunnel inbound route-maps (`RM-USER-{{ .Id }}-IN`) for stamping-eligible tunnels; generates `no` commands for full revert when `enabled: false`. -- **CLI** — full topology lifecycle commands (`create`, `update`, `delete`, `clear`, `list`); `link update` gains `--link-topology`; `link get` / `link list` display the field including derived EOS color value; `link topology list` warns on disconnected topologies. +- **CLI** — full topology lifecycle commands (`create`, `update`, `delete`, `clear`, `list`); `link update` gains `--link-topology`; `link get` / `link list` display the field including derived color; `link topology list` warns on disconnected topologies. - **SDKs** — `TopologyInfo` added to all three language SDKs; `link_topologies` field added to link deserialization structs. ### Operational @@ -632,10 +656,11 @@ The following sequence MUST be followed when deploying this RFC to any environme 1. Deploy the smart contract code update 2. Immediately create the UNICAST-DEFAULT topology via CLI: `doublezero link topology create --name unicast-default --constraint include-any` — this MUST happen before any new link activations are accepted. Link activation MUST fail with an explicit error (`"UNICAST-DEFAULT topology not found"`) if this step is skipped -3. Run the migration command to tag existing links and allocate loopback node segments for pre-existing accounts -4. Resume normal link activation workflow — new links will be auto-tagged at activation from this point +3. Immediately create the UNICAST-DRAINED topology via CLI: `doublezero link topology create --name unicast-drained --constraint exclude` — this MUST happen before any additional topologies are created. The program enforces that the second topology is named `unicast-drained` and rejects any other name until this invariant is satisfied +4. Run the migration command to tag existing links and allocate loopback node segments for pre-existing accounts +5. Resume normal link activation workflow — new links will be auto-tagged at activation from this point -Attempting to activate a link between steps 1 and 2 will fail with a clear error. There is no silent partial state. +Attempting to activate a link between steps 1 and 2 will fail with a clear error. There is no silent partial state. Attempting to create a third topology before step 3 is complete will fail with a clear error enforcing the UNICAST-DRAINED invariant. - Adding a new topology MUST NOT require a code change or deploy — DZF creates the `TopologyInfo` account via the CLI and the controller picks it up on the next reconciliation cycle once `enabled: true`. - `link_topologies` appends to the serialized layout and defaults to an empty vector on existing accounts. Existing links activated before this RFC will have `link_topologies = []` and MUST be tagged with UNICAST-DEFAULT as part of the testnet rollout migration before `enabled: true` is set. @@ -647,7 +672,7 @@ Attempting to activate a link between steps 1 and 2 will fail with a clear error |---|---| | Smart contract | `TopologyInfo` create/update/delete lifecycle; `AdminGroupBits` `ResourceExtension` allocation and no-reuse after deletion; foundation-only authorization; `link_topologies` assignment and clearing; delete blocked when links assigned; `clear` removes topology from all links; `link_topologies` cap at 8 enforced; `include_topologies` assignment on tenant; foundation-only authorization | | Controller (unit) | Interface config with and without topology; link tagging exclude list respected; remove/add diff on topology change; `router traffic-engineering` block with `color` field; `system-colored-tunnel-rib` BGP resolution config; single-topology and multi-topology per-tenant stamping; community stamping per-tenant and per-device; stamping exclude respected; full revert on `enabled: false`; new topology triggers config push to all devices | -| SDK (unit) | `TopologyInfo` Borsh round-trip; `link_topologies` field in `link get` / `link list` output; `include_topologies` field in `tenant get` / `tenant list` output across Go, Python, and TypeScript; EOS color value displayed correctly | +| SDK (unit) | `TopologyInfo` Borsh round-trip; `link_topologies` field in `link get` / `link list` output; `include_topologies` field in `tenant get` / `tenant list` output across Go, Python, and TypeScript; color displayed correctly | | End-to-end (cEOS) | Topology create → controller config push verified; admin-group applied and removed on interface; link tagging exclude list verified; flex-algo 128 topology includes only UNICAST-DEFAULT links; `system-colored-tunnel-rib` populated; VPN unicast resolves via color-1 tunnel; per-tenant single and multi-topology stamping verified; topology fallback verified; community stamping per-device verified; stamping exclude verified; multicast RPF unchanged; topology clear removes admin-groups; full revert on `enabled: false` verified | --- From 964c19fc0174e4ecd951f6c436757fc627ebade8 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 31 Mar 2026 00:14:48 -0500 Subject: [PATCH 05/49] =?UTF-8?q?rfc18:=20review=20pass=20=E2=80=94=20accu?= =?UTF-8?q?racy=20fixes,=20remove=20lab=20testing=20regurgitation,=20backf?= =?UTF-8?q?ill=20on=20topology=20creation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- rfcs/rfc18-link-classification-flex-algo.md | 156 ++++++++++---------- 1 file changed, 81 insertions(+), 75 deletions(-) diff --git a/rfcs/rfc18-link-classification-flex-algo.md b/rfcs/rfc18-link-classification-flex-algo.md index da58df6d98..e5513bdcd2 100644 --- a/rfcs/rfc18-link-classification-flex-algo.md +++ b/rfcs/rfc18-link-classification-flex-algo.md @@ -11,7 +11,7 @@ DoubleZero contributors operate links with different physical characteristics **Deliverables:** - `TopologyInfo` onchain account — DZF creates this to define a topology, with auto-assigned admin-group bit (from the `AdminGroupBits` `ResourceExtension`), flex-algo number, and derived color - `link_topologies: Vec` field on the serviceability link account — references assigned topologies; capped at 8 entries; only the first entry is used by the controller in this RFC -- Controller feature config file (`features.yaml`) — loaded at startup; gates flex-algo topology config, link admin-group tagging, and BGP color community stamping independently; replaces any onchain feature flag for this capability +- Controller feature config file (`features.yaml`) — loaded at startup; gates flex-algo topology config and link admin-group tagging via a single `enabled` flag; BGP color community stamping has granular per-tenant and per-device control; replaces any onchain feature flag for this capability - Controller logic — translates topologies into IS-IS TE admin-groups on interfaces, generates flex-algo topology definitions, configures `system-colored-tunnel-rib` as the BGP next-hop resolution source, and applies BGP color extended community route-maps per tunnel; all conditioned on the controller config **Scope:** @@ -43,8 +43,8 @@ IS-IS Flexible Algorithm provides the routing mechanism: each flex-algo defines - **Topology constraint** — Each `TopologyInfo` defines either an `IncludeAny` or `Exclude` constraint. `IncludeAny`: only links explicitly tagged with this topology participate. `Exclude`: all links except those tagged with this topology participate. UNICAST-DEFAULT uses `IncludeAny`. - **system-colored-tunnel-rib** — An EOS system RIB auto-populated when flex-algo definitions carry a `color` field. Keyed by (endpoint, color). Used by BGP next-hop resolution to steer VPN routes onto constrained topologies based on the BGP color extended community carried on the route. - **Topology vs color** — In this RFC, *topology* refers to a DZF-defined constrained IS-IS forwarding plane (a `TopologyInfo` account). *Color* refers to the EOS/BGP mechanism used to steer traffic onto a topology: the `color` field in an EOS flex-algo definition, the `EOS color value` derived as `admin_group_bit + 1`, and the BGP color extended community (`Color:CO(00):`) stamped on VPN routes. Every DZF topology has a corresponding EOS color, but the two concepts are distinct. -- **UNICAST-DEFAULT** — The reserved default topology. MUST be the first topology created by DZF and MUST be assigned admin-group bit 0, flex-algo 128, and color 1. These values are protocol invariants — the controller resolves the default tenant topology by looking up the `TopologyInfo` where `admin_group_bit == 0`, not by creation order. Applied to all links eligible for the default unicast topology. Flex-algo 128 uses `include-any UNICAST-DEFAULT`, so only explicitly tagged links participate in the unicast topology. Untagged links are excluded from unicast but remain available to multicast via IS-IS algo 0. -- **UNICAST-DRAINED** — The reserved drain topology. MUST be the second topology created by DZF and MUST be assigned admin-group bit 1, flex-algo 129, and color 2. These values are protocol invariants — the controller resolves the drained topology by looking up the `TopologyInfo` where `admin_group_bit == 1`. Constraint MUST be `Exclude`: only links tagged with UNICAST-DRAINED are excluded from each topology's constrained SPF. Drain is additive — adding UNICAST-DRAINED to `link_topologies` does not remove other topology assignments; the link's permanent tags remain unchanged. The controller injects `exclude {{ $drainBit }}` into every `include-any` flex-algo definition, so a drained link is pruned from all include-any topologies unconditionally (RFC 9350 §5.2.1: `exclude` is evaluated before `include-any` and MUST take precedence). To drain a link, add the UNICAST-DRAINED pubkey to `link_topologies`; to restore, remove it. +- **UNICAST-DEFAULT** — The reserved default unicast topology. MUST exist before any link can be activated. The controller resolves it by name via PDA seeds `[b"topology", b"unicast-default"]`. It will naturally be allocated bit 0 since it must be created before any other topology, but its bit is not otherwise special — the controller uses the name, not the bit, to identify it. Applied to all links at activation. Flex-algo uses `include-any UNICAST-DEFAULT`, so only explicitly tagged links participate in the unicast topology. Untagged links are excluded from unicast but remain available to multicast via IS-IS algo 0. +- **UNICAST-DRAINED** — A controller-managed IS-IS TE admin-group alias used to drain a link from all constrained unicast topologies. It is not a `TopologyInfo` PDA. The controller hardcodes the UNICAST-DRAINED bit as a constant; the `AdminGroupBits` bitmap is pre-marked at program initialization to ensure this bit is never allocated to a user topology. The controller always defines the corresponding `administrative-group alias UNICAST-DRAINED group ` on all devices and always injects `exclude ` into every `include-any` flex-algo definition. When a link's `unicast_drained` field is `true`, the controller appends `UNICAST-DRAINED` to that interface's `traffic-engineering administrative-group` alongside its permanent topology tags; the permanent tags are not changed. Because `exclude` is evaluated before `include-any` in flex-algo SPF computation (RFC 9350 §5.2.1), the drained link is pruned from all constrained unicast topologies simultaneously. Multicast (algo-0) is unaffected. Drain is a contributor-writable boolean — contributors may drain their own links as a capacity management tool. --- @@ -52,10 +52,9 @@ IS-IS Flexible Algorithm provides the routing mechanism: each flex-algo defines | Scenario | This RFC | Notes | |---|---|---| -| Default unicast topology via UNICAST-DEFAULT | ✅ | Core deliverable; all unicast-eligible links must be explicitly tagged | +| Default unicast topology via UNICAST-DEFAULT | ✅ | Core deliverable; new links are automatically tagged UNICAST-DEFAULT at activation — no contributor action required | | Multicast uses all links (algo 0) | ✅ | Natural PIM RPF behavior; includes both tagged and untagged links; no config required | | Multiple links in the same topology | ✅ | All tagged links participate together in the constrained topology | -| New links excluded from unicast by default | ✅ | `include-any` strictly excludes untagged links — verified in lab. New links must be explicitly tagged before they carry unicast traffic | | Per-tenant unicast path differentiation | ✅ | Architecture proven in lab (BGP color extended communities + `system-colored-tunnel-rib`). All unicast tenants receive color 1 (UNICAST-DEFAULT) by default; `include_topologies` overrides this to steer onto specific topologies | | Exclude a link from multicast | ❌ | PIM RPF uses IS-IS algo 0 unconditionally. No EOS mechanism can redirect multicast away from specific links within the current architecture | | Automated link selection by bandwidth or type | ❌ | Link tagging is manual DZF policy at this stage. `link.bandwidth` and `link.link_type` exist onchain and can drive automated selection in a future RFC | @@ -132,12 +131,21 @@ The program MUST validate `admin_group_bit <= 127` on `create` and MUST return a A `link_topologies: Vec` field is added to the serviceability `Link` account, capped at 8 entries. Each entry holds the pubkey of a `TopologyInfo` PDA. The field appends to the end of the serialized layout, defaulting to an empty vector on existing accounts. -The cap of 8 exists to keep the `Link` account size deterministic on-chain. Only the first entry (`link_topologies[0]`) is used by the controller in this RFC — multiple entries are reserved for future multi-topology-per-link support (e.g., a link participating in both UNICAST-DEFAULT and SHELBY topologies simultaneously, as validated in lab testing). +The cap of 8 exists to keep the `Link` account size deterministic onchain. Only the first entry (`link_topologies[0]`) is used by the controller in this RFC — multiple entries are reserved for future multi-topology-per-link support (e.g., a link participating in both UNICAST-DEFAULT and SHELBY topologies simultaneously, as validated in lab testing). -**Auto-tagging at activation:** when DZF activates a link, the activation processor MUST automatically set `link_topologies[0]` to the UNICAST-DEFAULT `TopologyInfo` pubkey (resolved by PDA seeds `[b"topology", b"unicast-default"]`). This preserves the existing contributor workflow — a link that passes DZF validation carries unicast traffic without any additional manual step. Foundation keys may subsequently override `link_topologies` to assign a different topology or remove the default tag for specialized links (e.g. multicast-only). +**Auto-tagging at activation:** when a contributor activates a link, the activation processor MUST automatically set `link_topologies[0]` to the UNICAST-DEFAULT `TopologyInfo` pubkey (resolved by PDA seeds `[b"topology", b"unicast-default"]`). The contributor workflow is unchanged — a link that passes DZF validation immediately carries unicast traffic without any additional step. Foundation keys may subsequently override `link_topologies` to assign a different topology. Links activated before this RFC was deployed have `link_topologies = []` and are handled by the migration command (see Migration for existing accounts). `link_topologies` overrides MUST only be made by keys in the DZF foundation allowlist. Contributors MUST NOT set this field directly. +A `unicast_drained: bool` field is added to the `Link` account, appended to the serialized layout and defaulting to `false` on existing accounts. This field is contributor-writable — a contributor may drain their own link as a capacity management tool. When `true`, the controller appends `UNICAST-DRAINED` to the interface's admin-group config alongside the permanent topology tags. The field is set independently of `link.status` — a link can be `soft-drained` and `unicast_drained: true` simultaneously; the two are orthogonal. + +```rust +// Contributor-writable +if let Some(unicast_drained) = value.unicast_drained { + link.unicast_drained = unicast_drained; +} +``` + ```rust // Foundation-only fields if globalstate.foundation_allowlist.contains(payer_account.key) { @@ -149,7 +157,7 @@ if globalstate.foundation_allowlist.contains(payer_account.key) { #### CLI -**Color lifecycle:** +**Topology lifecycle:** ``` doublezero link topology create --name --constraint @@ -159,7 +167,7 @@ doublezero link topology clear --name doublezero link topology list ``` -- `create` — creates a `TopologyInfo` PDA; allocates the lowest available admin-group bit from the `AdminGroupBits` `ResourceExtension`; derives and stores flex-algo number; stores the specified constraint (`include-any` or `exclude`). MUST fail if the name already exists. The first topology created MUST be named `unicast-default` and will be allocated bit 0 — this is a protocol invariant and the program MUST enforce it by rejecting any `create` instruction where the `AdminGroupBits` bitmap is empty and the name is not `unicast-default`. The second topology created MUST be named `unicast-drained` and will be allocated bit 1 — this is a protocol invariant and the program MUST enforce it by rejecting any `create` instruction where only bit 0 is allocated and the name is not `unicast-drained`. Device impact is controlled entirely by the controller feature config — no device config is generated until `flex_algo.enabled: true` is set in the config file. +- `create` — creates a `TopologyInfo` PDA; allocates the lowest available admin-group bit from the `AdminGroupBits` `ResourceExtension`; derives and stores flex-algo number; stores the specified constraint (`include-any` or `exclude`). MUST fail if the name already exists. The `AdminGroupBits` bitmap is pre-marked at program initialization to reserve the UNICAST-DRAINED bit, ensuring it is never allocated to a user topology. Device impact is controlled entirely by the controller feature config — no device config is generated until `flex_algo.enabled: true` is set in the config file. - `update` — reserved for future use; all fields are immutable after creation. No device config change. - `delete` — removes the `TopologyInfo` PDA onchain. MUST fail if any link still references this topology (use `clear` first). On the next reconciliation cycle, the controller removes the deleted topology's admin-group alias and flex-algo definition from all devices. Admin-group bits from deleted topologies MUST NOT be reused — the `AdminGroupBits` `ResourceExtension` bitmap persists allocated bits permanently. - `clear` — removes this topology from all links currently assigned to it, setting `link_topologies` to an empty vector on each. This is a multi-transaction sweep — one `LinkUpdateArgs` instruction is submitted per assigned link; it is not atomic. If the sweep fails partway through, the operator MUST re-run `clear`; the operation is idempotent and will only submit instructions for links that still reference the topology. The `delete` guard (which rejects if any link still references the topology) is the safety net — partial completion is safe because a re-run will clear the remaining references before deletion is attempted. On the next reconciliation cycle, the controller re-applies only the remaining topologies on each affected interface — if other topologies remain, `traffic-engineering administrative-group ` is applied; if no topologies remain, `no traffic-engineering administrative-group` is applied. @@ -181,20 +189,21 @@ doublezero link update --code --link-topology - `--link-topology ` MUST resolve the topology name to the corresponding `TopologyInfo` PDA pubkey before submitting the instruction — the onchain field stores pubkeys, not names. Sets `link_topologies[0]`. - `--link-topology default` sets `link_topologies` to an empty vector, removing any topology assignment. Use with caution — an untagged link will not participate in the unicast topology. -- `doublezero link get` and `doublezero link list` MUST include `link_topologies` in their output, showing the resolved topology names (or "default"). A link activated after UNICAST-DEFAULT is created will immediately display `link-topology: unicast-default` — no additional operator action is required. +- `doublezero link get` and `doublezero link list` MUST include `link_topologies` in their output, showing the resolved topology names (or "default"). -**Link drain and restore:** +**Unicast drain:** ``` -doublezero link drain --pubkey -doublezero link drain --code -doublezero link restore --pubkey -doublezero link restore --code +doublezero link update --code --unicast-drained true +doublezero link update --code --unicast-drained false +doublezero link update --pubkey --unicast-drained true +doublezero link update --pubkey --unicast-drained false ``` -- `drain` appends the UNICAST-DRAINED `TopologyInfo` pubkey to `link_topologies`. The link's existing topology assignments are unchanged. On the next reconciliation cycle, the controller detects the UNICAST-DRAINED entry and EOS applies `exclude ` in each include-any flex-algo definition, pruning the link from all constrained topologies. -- `restore` removes the UNICAST-DRAINED pubkey from `link_topologies`. The link's permanent topology assignments remain in place and are immediately re-eligible in constrained SPF on the next reconciliation cycle. -- Both commands MUST be restricted to foundation keys. `drain` MUST be idempotent — if the link is already drained, it MUST succeed silently. +- `--unicast-drained true` sets `link.unicast_drained = true`. On the next reconciliation cycle the controller appends `UNICAST-DRAINED` to the interface's admin-group config. The link is excluded from all constrained unicast topologies for all tenants. Multicast (algo-0) is unaffected. +- `--unicast-drained false` clears the flag. The interface admin-group reverts to the permanent topology tags only on the next reconciliation cycle. +- `doublezero link get` and `doublezero link list` MUST display `unicast-drained: true/false`. + #### Tenant topology assignment @@ -210,7 +219,6 @@ pub struct Tenant { `include_topologies` MUST only be set by foundation keys. This is a routing policy decision — contributors MUST NOT be able to steer their own traffic onto a different topology by modifying this field. -When a tenant has one entry in `include_topologies`, the controller resolves the `TopologyInfo` PDA and stamps its color on inbound routes for that tenant. When a tenant has multiple entries, the controller stamps all corresponding color values — EOS then selects the best available colored tunnel by IGP metric (lowest metric wins; highest color number breaks ties). This enables a fallback chain: if the preferred topology's tunnel becomes unavailable, EOS automatically falls back to the next-best color on the same prefix without the route going unresolved. This behavior has been verified in lab testing. **CLI:** @@ -259,10 +267,8 @@ features: The config file decouples onchain state preparation from device deployment. DZF can create topologies and tag links before any device receives flex-algo config, then enable features progressively: 1. DZF creates `TopologyInfo` accounts; links activated after this point are automatically tagged `UNICAST-DEFAULT` — no device impact while `enabled: false` -2. Set `enabled: true` and restart the controller — topology config is pushed to all devices on the next reconciliation cycle. No admin-group tagging or community stamping yet -3. Verify all devices show correct flex-algo state (`show isis flex-algo`, `show tunnel rib system-colored-tunnel-rib brief`) -4. Add specific links to the tagging config or leave the exclude list empty to tag all onchain-assigned links — restart controller -5. Add tenants or devices to `community_stamping` — restart controller. Stamping can be rolled out per-tenant or per-device to control which traffic begins using constrained topologies +2. Set `enabled: true` and restart the controller — topology config and link admin-group tagging are pushed to all devices on the next reconciliation cycle. Verify all devices show correct flex-algo state (`show isis flex-algo`, `show tunnel rib system-colored-tunnel-rib brief`) +3. Add tenants or devices to `community_stamping` — restart controller. Stamping can be rolled out per-tenant or per-device to control which traffic begins using constrained topologies **Precedence for community stamping:** a device is stamped if `all: true`, OR its pubkey is in `devices`, OR the tenant's pubkey is in `tenants` — unless the device's pubkey is in `exclude.devices`, which overrides all positive rules. @@ -281,23 +287,23 @@ Each link topology maps to an IS-IS TE admin-group bit via the `TopologyInfo` ac | Topology | Constraint | Admin-group bit | Flex-algo number | Color | Forwarding scope | |---|---|---|---|---|---| | (untagged) | — | — | — (algo 0) | — | All links | +| UNICAST-DRAINED | system constant | 1 | — (no flex-algo) | — | Exclude-only; injected into all include-any definitions | | unicast-default | include-any | 0 | 128 | 1 | Only UNICAST-DEFAULT tagged links | -| unicast-drained | exclude | 1 | 129 | 2 | All links except UNICAST-DRAINED tagged links | -The flex-algo definition MUST be configured on each DZD by the controller. The `color` field MUST be included and set to `admin_group_bit + 1`. The constraint type determines whether `include any` or `exclude` is used. Using UNICAST-DEFAULT as an example: +The flex-algo definition MUST be configured on each DZD by the controller. The `color` field MUST be included and set to `admin_group_bit + 1`. Every `include-any` flex-algo definition MUST include `exclude 1` (the UNICAST-DRAINED bit) — this is unconditional and not dependent on any link being drained. Using UNICAST-DEFAULT as an example: ``` router traffic-engineering administrative-group alias UNICAST-DEFAULT group 0 + administrative-group alias UNICAST-DRAINED group 1 flex-algo flex-algo 128 unicast-default - administrative-group include any 0 + administrative-group include any 0 exclude 1 color 1 ``` Flex-algo 128 ("unicast-default") computes an IS-IS SPF over only those links tagged `UNICAST-DEFAULT`. The `color 1` field causes EOS to install these tunnels in `system-colored-tunnel-rib` keyed by (endpoint, 1). Devices that participate in flex-algo 128 advertise both an algo-0 node-segment and an algo-128 node-segment via their loopback. -**Operational implication:** Links are automatically tagged `UNICAST-DEFAULT` at activation — no manual DZF step is required for the common case. The contributor workflow is unchanged: a link that passes DZF validation immediately participates in the unicast topology. The `link topology list` command SHOULD warn if a topology appears disconnected based on the set of tagged links, and SHOULD warn if any activated links have an empty `link_topologies` (indicating a link activated before this RFC that has not been migrated). #### Universal participation requirement @@ -328,7 +334,7 @@ router bgp 65342 next-hop resolution ribs tunnel-rib colored system-colored-tunnel-rib tunnel-rib system-tunnel-rib ``` -`system-colored-tunnel-rib` is auto-populated by EOS when flex-algo definitions carry a `color` field. A VPN route carrying `Color:CO(00):1` resolves its next-hop through the color-1 (unicast-default, algo 128) tunnel to that endpoint. Routes without a color community fall through to `tunnel-rib system-tunnel-rib`, which is auto-populated by IS-IS SR (algo-0) tunnels. `system-connected` is deliberately omitted — this ensures all VPN traffic uses MPLS forwarding (either colored flex-algo or algo-0 SR) and never falls back to plain IP. Verified in lab testing: with only `tunnel-rib colored system-colored-tunnel-rib` configured, uncolored VPN routes are received but their next-hops cannot be resolved and they never make it into the VRF routing table. +`system-colored-tunnel-rib` is auto-populated by EOS when flex-algo definitions carry a `color` field. A VPN route carrying `Color:CO(00):1` resolves its next-hop through the color-1 (unicast-default, algo 128) tunnel to that endpoint. Routes without a color community fall through to `tunnel-rib system-tunnel-rib`, which is auto-populated by IS-IS SR (algo-0) tunnels. `system-connected` is deliberately omitted — this ensures all VPN traffic uses MPLS forwarding (either colored flex-algo or algo-0 SR) and never falls back to plain IP. Without `tunnel-rib system-tunnel-rib`, uncolored VPN routes would be received but their next-hops could not be resolved and they would never make it into the VRF routing table. #### Inbound route-map color stamping @@ -346,14 +352,10 @@ route-map RM-USER-{{ .Id }}-IN permit 10 `.TenantTopologyEosColorValues` is resolved by the controller from the tunnel's tenant: - If `tenant.include_topologies` is non-empty, resolve each `TopologyInfo` PDA and compute `AdminGroupBit + 1` for each. All resolved color values are stamped in a single `set extcommunity color` statement (e.g., `set extcommunity color 1 color 2`). -- If `tenant.include_topologies` is empty, use the default unicast color: resolve the `TopologyInfo` where `admin_group_bit == 0` (UNICAST-DEFAULT, color 1). - -When multiple colors are stamped, EOS selects the colored tunnel with the lowest IGP metric to the next-hop. If two colors tie on metric, the highest color number wins. If a preferred color's tunnel becomes unavailable (e.g., the destination withdraws its node-segment for that algorithm), EOS automatically falls back to the next-best available color — the route remains installed throughout with no disruption. This fallback behavior has been verified in lab testing. +- If `tenant.include_topologies` is empty, use the default unicast color: resolve the UNICAST-DEFAULT `TopologyInfo` by name (`[b"topology", b"unicast-default"]`) and compute its color as `admin_group_bit + 1`. Multicast tunnels do not receive the color community — multicast RPF resolves via IS-IS algo 0 and does not use `system-colored-tunnel-rib`. -Routes arrive on the client-facing session, are stamped with both the standard community and the color extended community in a single pass, and are then advertised into VPN-IPv4 carrying both. No new route-map blocks or `network` statement changes are required. - --- ### Controller Changes @@ -368,16 +370,17 @@ Inside the existing `{{- range .Device.Interfaces }}` block, after the `isis met {{- if and .Ip.IsValid .IsPhysical .Metric .IsLink (not .IsSubInterfaceParent) (not .IsCYOA) (not .IsDIA) }} traffic-engineering {{- if and .LinkTopologies (not ($.Config.FlexAlgo.LinkTagging.IsExcluded .PubKey)) }} - traffic-engineering administrative-group {{ $.Strings.Join " " ($.Strings.ToUpperEach .LinkTopologyNames) }} + traffic-engineering administrative-group {{ $.Strings.Join " " ($.Strings.ToUpperEach .LinkTopologyNames) }}{{ if .UnicastDrained }} UNICAST-DRAINED{{ end }} {{- else }} no traffic-engineering administrative-group {{- end }} {{- end }} ``` -`.LinkTopologies` is the resolved list of `TopologyInfo` accounts from `link.link_topologies`; it is empty when `link_topologies` is empty. `.LinkTopologyNames` is the corresponding list of names. The controller renders all topologies as admin-group names in a space-separated list in a single command — EOS overwrites the existing admin-group assignment with exactly this set. This means: +`.LinkTopologies` is the resolved list of `TopologyInfo` accounts from `link.link_topologies`; it is empty when `link_topologies` is empty. `.LinkTopologyNames` is the corresponding list of names. `.UnicastDrained` is `link.unicast_drained`. The controller renders all permanent topology names followed by `UNICAST-DRAINED` (if drained) in a single command — EOS overwrites the existing admin-group assignment with exactly this set. This means: - A link transitioning from two topologies to one re-applies only the surviving topology, atomically replacing the previous set - A link losing its last topology receives `no traffic-engineering administrative-group` +- Draining appends `UNICAST-DRAINED` to the existing set without altering permanent tags - The targeted `no traffic-engineering administrative-group ` command is never used, avoiding the EOS behavior where it would remove all groups regardless of the name specified Interface-level admin-group tagging is conditioned on `$.Config.FlexAlgo.Enabled` alone — since an interface may have topologies assigned onchain while the feature is disabled. @@ -390,16 +393,16 @@ Add after the `router isis 1` block, conditional on topologies being defined and {{- if and $.Config.FlexAlgo.Enabled .LinkTopologies }} router traffic-engineering router-id ipv4 {{ .Device.Vpn4vLoopbackIP }} + administrative-group alias UNICAST-DRAINED group 1 {{- range .LinkTopologies }} administrative-group alias {{ $.Strings.ToUpper .Name }} group {{ .AdminGroupBit }} {{- end }} ! flex-algo - {{- $drainBit := $.DrainedAdminGroupBit }} {{- range .LinkTopologies }} flex-algo {{ .FlexAlgoNumber }} {{ .Name }} {{- if eq .Constraint "include-any" }} - administrative-group include any {{ .AdminGroupBit }} exclude {{ $drainBit }} + administrative-group include any {{ .AdminGroupBit }} exclude 1 {{- else }} administrative-group exclude {{ .AdminGroupBit }} {{- end }} @@ -408,7 +411,7 @@ router traffic-engineering {{- end }} ``` -`.LinkTopologies` is the ordered list of `TopologyInfo` accounts, sorted by `AdminGroupBit`. `.Color` is computed as `AdminGroupBit + 1`. `$.DrainedAdminGroupBit` is the `AdminGroupBit` of the UNICAST-DRAINED `TopologyInfo`, resolved by PDA seeds `[b"topology", b"unicast-drained"]`. The flex-algo name (e.g., `unicast-default`) is the topology name stored in `TopologyInfo`. +`.LinkTopologies` is the ordered list of `TopologyInfo` accounts, sorted by `AdminGroupBit`. `.Color` is computed as `AdminGroupBit + 1`. `UNICAST-DRAINED group 1` is a system constant — always rendered first, not derived from any `TopologyInfo` PDA. The flex-algo name (e.g., `unicast-default`) is the topology name stored in `TopologyInfo`. When `$.Config.FlexAlgo.Enabled` is false, the controller generates `no router traffic-engineering` to remove any previously-pushed config. @@ -481,19 +484,19 @@ pub flex_algo_node_segments: Vec, At interface activation time, one `FlexAlgoNodeSegment` is allocated per known `TopologyInfo` account and appended to the list. Each `node_segment_idx` is allocated from the `SegmentRoutingIds` `ResourceExtension` account, following the same pattern as the existing `node_segment_idx`. Entries are deallocated on `remove`. +**Topology creation backfill:** When a new `TopologyInfo` account is created, the creation processor MUST iterate all existing `Interface` accounts with `loopback_type = Vpnv4` and allocate a `FlexAlgoNodeSegment` entry for the new topology on each. This is automatic — no operator action is required. The controller picks up the new entries on its next cycle and pushes the updated `node-segment` config to all devices. This ensures all devices always advertise a node-SID for every defined topology, maintaining the universal participation requirement. + In the template, `.FlexAlgoNodeSegments` is accessed directly on the current interface (the `.` context within `{{- range .Device.Interfaces }}`). The controller populates this from the interface's onchain `flex_algo_node_segments` list during rendering, resolving the topology name from each entry's `TopologyInfo` pubkey. This is intentionally distinct from `.LinkTopologies` (which describes which topologies a specific link is tagged with) — the loopback template is concerned with which topologies this device participates in, not with link tagging. **Migration for existing accounts:** A one-time `doublezero-admin` CLI migration command MUST be provided covering two tasks: 1. **Links** — iterate all existing `Link` accounts with `link_topologies = []` and set `link_topologies[0]` to the UNICAST-DEFAULT `TopologyInfo` pubkey. Links activated after this RFC are auto-tagged at activation; this migration covers links activated before the RFC was deployed. -2. **Vpnv4 loopbacks** — iterate all existing `Interface` accounts with `loopback_type = Vpnv4` and allocate a `FlexAlgoNodeSegment` entry for each known `TopologyInfo` account. Existing `node_segment_idx` assignments (algo-0) are unchanged — this is purely additive. Loopbacks activated after this RFC will have entries allocated at activation time. +2. **Vpnv4 loopbacks** — iterate all existing `Interface` accounts with `loopback_type = Vpnv4` and allocate a `FlexAlgoNodeSegment` entry for each known `TopologyInfo` account. Existing `node_segment_idx` assignments (algo-0) are unchanged — this is purely additive. Loopbacks activated after this RFC will have entries allocated at activation time; topologies created after this RFC will be backfilled automatically at topology creation time. The migration command MUST be idempotent — re-running it MUST skip already-migrated accounts and only process those still requiring migration. It MUST emit a summary on completion (e.g. `migrated 12 links, 4 loopbacks; skipped 3 already migrated`). A `--dry-run` flag MUST be supported to preview the accounts that would be migrated without applying any changes. The controller MUST check at startup that no Vpnv4 loopback has an empty `flex_algo_node_segments` list when `flex_algo.enabled: true` is set. If any unset loopbacks are found, `enabled: true` MUST be treated as a no-op for that startup cycle — the controller MUST NOT push any flex-algo config to any device, MUST emit a prominent error identifying the unset loopbacks by pubkey, and MUST direct the operator to run the migration command and restart. The `features.yaml` flag is not modified. Flex-algo config will not be applied until the migration is complete and the controller is restarted cleanly. This prevents silently pushing a broken topology where some devices are unreachable via the constrained path. -Without a flex-algo node-SID on the loopback, remote devices cannot compute a valid constrained path to this device and VPN routes to it will not resolve via the colored tunnel RIB. - Interface admin-group blocks are conditional on `.LinkTopologies` being non-empty. The flex-algo node-segment lines within the loopback block are conditional on `.FlexAlgoNodeSegments` being non-empty — if the list is empty, the `range` loop produces no output and only the algo-0 `node-segment` line is rendered. Devices with no topologies defined produce identical config to today. --- @@ -509,10 +512,8 @@ Interface admin-group blocks are conditional on `.LinkTopologies` being non-empt #### Smart contract (integration tests) **TopologyInfo lifecycle:** -- A foundation key MUST be able to create a `TopologyInfo` account with a name; admin-group bit MUST be allocated from the `AdminGroupBits` `ResourceExtension` starting at 0, and flex-algo number MUST be 128. -- Creating a second topology MUST allocate bit 1 from the `ResourceExtension` and flex-algo 129. -- Creating any topology before `unicast-default` (bitmap empty) with a name other than `unicast-default` MUST be rejected. -- Creating any topology as the second topology (only bit 0 allocated) with a name other than `unicast-drained` MUST be rejected. +- A foundation key MUST be able to create a `TopologyInfo` account; admin-group bit MUST be allocated from the `AdminGroupBits` `ResourceExtension`. The first topology will receive bit 0 and flex-algo number 128. +- Creating a second topology MUST allocate bit 2 — the UNICAST-DRAINED bit is pre-marked in the bitmap at initialization and MUST NOT be allocated to a user topology. - A non-foundation key MUST NOT be able to create a `TopologyInfo` account; the instruction MUST be rejected with an authorization error. - All `TopologyInfo` fields are immutable after creation; an `update` instruction MUST be rejected or be a no-op. - A non-foundation key MUST NOT be able to update a `TopologyInfo` account. @@ -521,7 +522,7 @@ Interface admin-group blocks are conditional on `.LinkTopologies` being non-empt - After `clear`, all links previously assigned the topology MUST have `link_topologies = []`. - After `clear` followed by `delete`, the `TopologyInfo` PDA MUST be absent. - Admin-group bits from deleted topologies MUST NOT be reused by subsequently created topologies; the `AdminGroupBits` `ResourceExtension` bitmap MUST persist the allocated bit after PDA deletion. -- After `delete`, the controller MUST NOT generate removal commands for the deleted topology's admin-group alias, flex-algo definition, or IS-IS TE config — device-side cleanup is deferred. +- After `delete`, the controller MUST remove the deleted topology's admin-group alias and flex-algo definition from all devices on the next reconciliation cycle — the `TopologyInfo` PDA is absent, so the controller no longer includes it in rendered config. **Tenant topology assignment:** - `include_topologies` MUST default to an empty vector on a newly created tenant account and on existing accounts deserialized from pre-upgrade binary data. @@ -539,21 +540,28 @@ Interface admin-group blocks are conditional on `.LinkTopologies` being non-empt - Setting `link_topologies[0]` to a pubkey that does not correspond to a valid `TopologyInfo` account MUST be rejected. - `link_topologies` MUST NOT exceed 8 entries; an instruction submitting more than 8 MUST be rejected. +**Unicast drain:** +- `unicast_drained` MUST default to `false` on existing accounts deserialized from pre-upgrade binary data. +- A contributor key MUST be able to set `unicast_drained = true` on their own link. +- A contributor key MUST NOT be able to set `unicast_drained` on a link they do not own; the instruction MUST be rejected with an authorization error. +- Setting `unicast_drained = true` MUST NOT modify `link_topologies` or `link.status`. + #### Controller (unit tests) - A link with `link_topologies = []` MUST produce interface config with no `traffic-engineering administrative-group` line. -- A link with `link_topologies[0]` referencing a `TopologyInfo` with bit 0, name "unicast-default", and constraint `IncludeAny` MUST produce interface config with `traffic-engineering administrative-group UNICAST-DEFAULT`. +- A link with `link_topologies[0]` referencing the UNICAST-DEFAULT `TopologyInfo` (name "unicast-default", constraint `IncludeAny`) MUST produce interface config with `traffic-engineering administrative-group UNICAST-DEFAULT`. - A link in `link_tagging.exclude.links` MUST produce `no traffic-engineering administrative-group` regardless of onchain `link_topologies` assignment. - Transitioning a link from a topology to default MUST produce a `no traffic-engineering administrative-group` diff. - Transitioning a link from one topology to another MUST produce the correct remove/add diff. +- The `router traffic-engineering` block MUST always include `administrative-group alias UNICAST-DRAINED group 1` when `enabled: true`, regardless of whether any link is drained. - The `router traffic-engineering` block MUST include `color ` on each flex-algo definition. -- Each `include-any` flex-algo definition MUST include `exclude ` in addition to `include any `. An `exclude`-constraint topology MUST NOT have the drained-bit injected. -- A link with UNICAST-DRAINED in `link_topologies` alongside UNICAST-DEFAULT MUST produce interface config with `traffic-engineering administrative-group UNICAST-DEFAULT UNICAST-DRAINED`. -- Draining a link (adding UNICAST-DRAINED to `link_topologies`) MUST NOT remove other topology assignments from the interface config. -- Restoring a link (removing UNICAST-DRAINED from `link_topologies`) MUST produce interface config identical to a never-drained link with the same permanent topology assignments. +- Each `include-any` flex-algo definition MUST include `exclude 1` unconditionally. An `exclude`-constraint topology MUST NOT have `exclude 1` injected. +- A link with `unicast_drained = true` and `link_topologies[0] = UNICAST-DEFAULT` MUST produce interface config `traffic-engineering administrative-group UNICAST-DEFAULT UNICAST-DRAINED`. +- A link with `unicast_drained = true` MUST NOT have `link_topologies` modified — permanent tags are unchanged. +- A link with `unicast_drained = false` after previously being `true` MUST produce interface config with only the permanent topology tags, identical to a never-drained link. - The BGP `next-hop resolution ribs tunnel-rib colored system-colored-tunnel-rib tunnel-rib system-tunnel-rib` config MUST be generated correctly when `enabled: true`. - A per-tunnel inbound route-map MUST include `set extcommunity color 1` for a unicast tenant with empty `include_topologies` when the tenant or device is in the `community_stamping` config and `.LinkTopologies` is non-empty. -- A per-tunnel inbound route-map MUST include `set extcommunity color 1 color 2` for a unicast tenant with `include_topologies` referencing two `TopologyInfo` accounts (bits 0 and 1). +- A per-tunnel inbound route-map MUST include `set extcommunity color 1 color 3` for a unicast tenant with `include_topologies` referencing two `TopologyInfo` accounts (bits 0 and 2 — the UNICAST-DRAINED bit is pre-marked and skipped by the allocator). - A per-tunnel inbound route-map MUST NOT include `set extcommunity color` when the device is in `community_stamping.exclude.devices`. - A new `TopologyInfo` account detected on reconciliation MUST cause the controller to push updated config to all devices. - **Config disabled:** With `TopologyInfo` accounts defined, links tagged, and `enabled: false`, the controller MUST generate `no router traffic-engineering`, no flex-algo IS-IS config, no `next-hop resolution ribs` line, and no `set extcommunity color` in any route-map. Device config MUST be identical to a network with no topologies defined. @@ -569,22 +577,23 @@ Interface admin-group blocks are conditional on `.LinkTopologies` being non-empt #### End-to-end (cEOS testcontainers) -- **Topology creation**: After a foundation key creates a `TopologyInfo` for "unicast-default" (bit 0, flex-algo 128, constraint include-any) and `enabled: true` is set, the controller MUST push `router traffic-engineering` config with `administrative-group alias UNICAST-DEFAULT group 0`, `flex-algo 128 unicast-default administrative-group include any 0 color 1`, and the BGP `next-hop resolution ribs` line to all devices. +- **Topology creation**: After a foundation key creates a `TopologyInfo` for "unicast-default" (constraint include-any) and `enabled: true` is set, the controller MUST push `router traffic-engineering` config including `administrative-group alias UNICAST-DRAINED group 1`, `administrative-group alias UNICAST-DEFAULT group `, the corresponding `flex-algo` definition with `include any exclude 1 color `, and the BGP `next-hop resolution ribs` line to all devices. - **Admin-group application**: After a foundation key sets `link_topologies[0]` on a link to the unicast-default `TopologyInfo` pubkey, `show traffic-engineering database` on the connected devices MUST reflect `UNICAST-DEFAULT` admin-group on the interface. Clearing the topology MUST remove the admin-group. - **Link tagging exclude**: A link in `link_tagging.exclude.links` MUST NOT have an admin-group applied even when `link_topologies[0]` is set onchain. - **Flex-algo topology**: With links tagged UNICAST-DEFAULT, `show isis flex-algo` on participating devices MUST show algo 128 including only UNICAST-DEFAULT links. Untagged links MUST be absent from the algo-128 LSDB view. - **Colored tunnel RIB**: `show tunnel rib system-colored-tunnel-rib brief` MUST show (endpoint, color 1) entries for each participating device, resolving via unicast-default tunnels. - **VPN unicast path selection**: A BGP VPN-IPv4 route carrying `Color:CO(00):1` MUST resolve its next-hop through the color-1 (unicast-default) tunnel in `system-colored-tunnel-rib`, traversing only UNICAST-DEFAULT tagged links. -- **Per-tenant topology — single**: A tenant with `include_topologies = [SHELBY pubkey]` MUST have `Color:CO(00):2` stamped on its inbound routes. A tenant with empty `include_topologies` (default) MUST have `Color:CO(00):1` (UNICAST-DEFAULT). -- **Per-tenant topology — multi**: A tenant with `include_topologies = [UNICAST-DEFAULT pubkey, SHELBY pubkey]` MUST have both `Color:CO(00):1` and `Color:CO(00):2` stamped. `show ip route vrf ` MUST show the lower-metric color tunnel selected for next-hop resolution. +- **Per-tenant topology — single**: A tenant with `include_topologies = [SHELBY pubkey]` MUST have `Color:CO(00):3` stamped on its inbound routes (SHELBY receives bit 2 — the UNICAST-DRAINED bit is pre-marked and skipped — giving color 3). A tenant with empty `include_topologies` (default) MUST have `Color:CO(00):1` (UNICAST-DEFAULT). +- **Per-tenant topology — multi**: A tenant with `include_topologies = [UNICAST-DEFAULT pubkey, SHELBY pubkey]` MUST have both `Color:CO(00):1` and `Color:CO(00):3` stamped. `show ip route vrf ` MUST show the lower-metric color tunnel selected for next-hop resolution. - **Per-tenant topology — fallback**: Removing a device's node-segment for the preferred topology's algorithm MUST cause EOS to fall back to the next available color on the same prefix without the route going unresolved. - **Community stamping — per device**: A tenant on a device in `community_stamping.devices` MUST have the color community on its inbound routes. The same tenant on a device NOT in the config MUST NOT have the color community. - **Community stamping — exclude**: A device in `community_stamping.exclude.devices` MUST NOT have `set extcommunity color` applied regardless of `all` or `tenants` settings. - **Multicast path isolation**: PIM RPF for a multicast source MUST continue to resolve via IS-IS algo 0 (all links, including both tagged and untagged) regardless of BGP next-hop resolution config. - **Topology clear**: After `link topology clear --name unicast-default` removes the topology from all links, the controller MUST generate `no traffic-engineering administrative-group UNICAST-DEFAULT` on all previously-tagged interfaces on the next reconciliation cycle. -- **Link drain**: After `doublezero link drain` on a UNICAST-DEFAULT tagged link, `show traffic-engineering database` MUST show both `UNICAST-DEFAULT` and `UNICAST-DRAINED` admin-groups on the interface. `show isis flex-algo` MUST show the link absent from the algo-128 (unicast-default) constrained topology. The link MUST remain visible in algo-0. -- **Link restore**: After `doublezero link restore` on a drained link, `show traffic-engineering database` MUST show only the permanent topology tags. `show isis flex-algo` MUST show the link restored to the constrained topology. -- **Drain exclude precedence**: The `exclude UNICAST-DRAINED` constraint in each `include-any` flex-algo definition MUST take precedence over `include any ` — a link tagged with both UNICAST-DEFAULT and UNICAST-DRAINED MUST NOT appear in the algo-128 SPF (verified per RFC 9350 §5.2.1 exclude-before-include evaluation). +- **Unicast drain**: After `doublezero link update --unicast-drained true` on a UNICAST-DEFAULT tagged link, `show traffic-engineering database` MUST show both `UNICAST-DEFAULT` and `UNICAST-DRAINED` admin-groups on the interface. `show isis flex-algo` MUST show the link absent from the algo-128 (unicast-default) constrained topology. The link MUST remain visible in algo-0. `show ip mroute` MUST confirm multicast RPF is unchanged. +- **Unicast restore**: After `doublezero link update --unicast-drained false`, `show traffic-engineering database` MUST show only the permanent topology tags. `show isis flex-algo` MUST show the link restored to the constrained topology. +- **Drain exclude precedence**: The `exclude 1` clause in each `include-any` flex-algo definition MUST take precedence over `include any ` — a link with `unicast_drained = true` MUST NOT appear in any constrained unicast topology's SPF regardless of its permanent tags (RFC 9350 §5.2.1). +- **UNICAST-DRAINED alias always present**: With `enabled: true` and no links drained, `show traffic-engineering` MUST show `administrative-group alias UNICAST-DRAINED group 1` defined on all devices. The alias and `exclude 1` clauses are structural, not conditional on drain state. - **Revert**: Setting `enabled: false` and restarting the controller MUST result in all flex-algo config being removed from all devices on the next reconciliation cycle. #### EOS Verification @@ -643,10 +652,10 @@ MUST confirm PIM RPF resolves via the IS-IS unicast RIB (algo 0). The incoming i ### Codebase -- **serviceability** — new `TopologyInfo` PDA (foundation-managed, one per topology); new `AdminGroupBits` `ResourceExtension` account for persistent bit allocation; new `link_topologies: Vec` field (cap 8) on `Link`; new `link_topologies: Option>` field on `LinkUpdateArgs` with foundation-only write restriction; new `flex_algo_node_segments: Vec` field on `Interface` (one entry per `TopologyInfo` topology, each with its own allocated node segment index); new `include_topologies: Vec` field on `Tenant` with foundation-only write restriction. -- **controller** — new `-features-config` flag and `features.yaml` config file; reads `link.link_topologies[0]`, resolves `TopologyInfo` PDAs, generates IS-IS TE admin-group config on interfaces (respecting `link_tagging.exclude.links`), flex-algo definitions with `color` field, `system-colored-tunnel-rib` BGP resolution profile, and adds `set extcommunity color` to the existing per-tunnel inbound route-maps (`RM-USER-{{ .Id }}-IN`) for stamping-eligible tunnels; generates `no` commands for full revert when `enabled: false`. -- **CLI** — full topology lifecycle commands (`create`, `update`, `delete`, `clear`, `list`); `link update` gains `--link-topology`; `link get` / `link list` display the field including derived color; `link topology list` warns on disconnected topologies. -- **SDKs** — `TopologyInfo` added to all three language SDKs; `link_topologies` field added to link deserialization structs. +- **serviceability** — new `TopologyInfo` PDA (foundation-managed, one per topology); new `AdminGroupBits` `ResourceExtension` account for persistent bit allocation (UNICAST-DRAINED bit pre-marked at initialization; user topologies start at bit 2); new `link_topologies: Vec` field (cap 8) on `Link` with foundation-only write restriction; new `unicast_drained: bool` field on `Link` (contributor-writable, defaults to `false`); new `flex_algo_node_segments: Vec` field on `Interface` (one entry per `TopologyInfo` topology, each with its own allocated node segment index); new `include_topologies: Vec` field on `Tenant` with foundation-only write restriction. +- **controller** — new `-features-config` flag and `features.yaml` config file; reads `link.link_topologies[0]` and `link.unicast_drained`, resolves `TopologyInfo` PDAs, generates IS-IS TE admin-group config on interfaces (appending `UNICAST-DRAINED` when `unicast_drained = true`, respecting `link_tagging.exclude.links`), always defines `administrative-group alias UNICAST-DRAINED group 1` and injects `exclude 1` into every `include-any` flex-algo definition, `system-colored-tunnel-rib` BGP resolution profile, and adds `set extcommunity color` to the existing per-tunnel inbound route-maps (`RM-USER-{{ .Id }}-IN`) for stamping-eligible tunnels; generates `no` commands for full revert when `enabled: false`. +- **CLI** — full topology lifecycle commands (`create`, `update`, `delete`, `clear`, `list`); `link update` gains `--link-topology` and `--unicast-drained true|false`; `link get` / `link list` display both fields including derived color; `link topology list` warns on disconnected topologies. +- **SDKs** — `TopologyInfo` added to all three language SDKs; `link_topologies` and `unicast_drained` fields added to link deserialization structs. ### Operational @@ -655,31 +664,30 @@ MUST confirm PIM RPF resolves via the IS-IS unicast RIB (algo 0). The incoming i The following sequence MUST be followed when deploying this RFC to any environment: 1. Deploy the smart contract code update -2. Immediately create the UNICAST-DEFAULT topology via CLI: `doublezero link topology create --name unicast-default --constraint include-any` — this MUST happen before any new link activations are accepted. Link activation MUST fail with an explicit error (`"UNICAST-DEFAULT topology not found"`) if this step is skipped -3. Immediately create the UNICAST-DRAINED topology via CLI: `doublezero link topology create --name unicast-drained --constraint exclude` — this MUST happen before any additional topologies are created. The program enforces that the second topology is named `unicast-drained` and rejects any other name until this invariant is satisfied -4. Run the migration command to tag existing links and allocate loopback node segments for pre-existing accounts +2. Create the UNICAST-DEFAULT topology via CLI: `doublezero link topology create --name unicast-default --constraint include-any` — this MUST happen before any new link activations are accepted. Link activation MUST fail with an explicit error (`"UNICAST-DEFAULT topology not found"`) if this step is skipped +3. Run the migration command to tag existing links and allocate loopback node segments for pre-existing accounts +4. Set `flex_algo.enabled: true` in `features.yaml` and restart the controller — this triggers the first push of flex-algo config to all devices 5. Resume normal link activation workflow — new links will be auto-tagged at activation from this point -Attempting to activate a link between steps 1 and 2 will fail with a clear error. There is no silent partial state. Attempting to create a third topology before step 3 is complete will fail with a clear error enforcing the UNICAST-DRAINED invariant. +Attempting to activate a link between steps 1 and 2 will fail with a clear error. There is no silent partial state. UNICAST-DRAINED requires no bootstrap step — the controller defines the alias and injects the exclude clause as system constants, regardless of whether any link is drained. -- Adding a new topology MUST NOT require a code change or deploy — DZF creates the `TopologyInfo` account via the CLI and the controller picks it up on the next reconciliation cycle once `enabled: true`. -- `link_topologies` appends to the serialized layout and defaults to an empty vector on existing accounts. Existing links activated before this RFC will have `link_topologies = []` and MUST be tagged with UNICAST-DEFAULT as part of the testnet rollout migration before `enabled: true` is set. -- The transition from no-color to color-1 on all tenant VRFs is a one-time controller config push. The template section order enforces the correct sequencing within a single reconciliation cycle: the `router traffic-engineering` block and `address-family vpn-ipv4 next-hop resolution` config appear before the `route-map RM-USER-*-IN` blocks in `tunnel.tmpl`, so EOS applies them top-to-bottom in the correct order. Applying the route-map before the RIB is configured would cause VPN routes to go unresolved. +- Adding a new topology MUST NOT require a code change or deploy — DZF creates the `TopologyInfo` account via the CLI, the creation processor automatically backfills `FlexAlgoNodeSegment` entries on all existing Vpnv4 loopbacks, and the controller picks up the new topology and updated loopback entries on the next reconciliation cycle. ### Testing | Layer | Tests | |---|---| -| Smart contract | `TopologyInfo` create/update/delete lifecycle; `AdminGroupBits` `ResourceExtension` allocation and no-reuse after deletion; foundation-only authorization; `link_topologies` assignment and clearing; delete blocked when links assigned; `clear` removes topology from all links; `link_topologies` cap at 8 enforced; `include_topologies` assignment on tenant; foundation-only authorization | -| Controller (unit) | Interface config with and without topology; link tagging exclude list respected; remove/add diff on topology change; `router traffic-engineering` block with `color` field; `system-colored-tunnel-rib` BGP resolution config; single-topology and multi-topology per-tenant stamping; community stamping per-tenant and per-device; stamping exclude respected; full revert on `enabled: false`; new topology triggers config push to all devices | -| SDK (unit) | `TopologyInfo` Borsh round-trip; `link_topologies` field in `link get` / `link list` output; `include_topologies` field in `tenant get` / `tenant list` output across Go, Python, and TypeScript; color displayed correctly | -| End-to-end (cEOS) | Topology create → controller config push verified; admin-group applied and removed on interface; link tagging exclude list verified; flex-algo 128 topology includes only UNICAST-DEFAULT links; `system-colored-tunnel-rib` populated; VPN unicast resolves via color-1 tunnel; per-tenant single and multi-topology stamping verified; topology fallback verified; community stamping per-device verified; stamping exclude verified; multicast RPF unchanged; topology clear removes admin-groups; full revert on `enabled: false` verified | +| Smart contract | `TopologyInfo` create/update/delete lifecycle; `AdminGroupBits` `ResourceExtension` allocation and no-reuse after deletion; UNICAST-DRAINED bit pre-marked at program initialization; foundation-only authorization; `link_topologies` assignment and clearing; delete blocked when links assigned; `clear` removes topology from all links; `link_topologies` cap at 8 enforced; `include_topologies` assignment on tenant; `unicast_drained` contributor-writable set/unset on Link; `flex_algo_node_segments` allocated at loopback activation and deallocated on `remove`; topology creation triggers `FlexAlgoNodeSegment` backfill on all existing Vpnv4 loopbacks | +| Controller (unit) | Interface config with and without topology; link tagging exclude list respected; remove/add diff on topology change; `router traffic-engineering` block with `color` field; UNICAST-DRAINED alias and `exclude 1` always present in all `include-any` definitions regardless of drain state; `unicast_drained: true` appends UNICAST-DRAINED to interface admin-group; `unicast_drained: false` removes UNICAST-DRAINED from interface admin-group; loopback node-segment lines rendered correctly — one per topology from `flex_algo_node_segments`; `system-colored-tunnel-rib` BGP resolution config; single-topology and multi-topology per-tenant stamping; community stamping per-tenant and per-device; stamping exclude respected; startup check blocks `enabled: true` when any Vpnv4 loopback has empty `flex_algo_node_segments`; full revert on `enabled: false`; new topology triggers config push to all devices | +| SDK (unit) | `TopologyInfo` Borsh round-trip; `link_topologies` and `unicast_drained` fields in `link get` / `link list` output; `include_topologies` field in `tenant get` / `tenant list` output across Go, Python, and TypeScript; color displayed correctly | +| End-to-end (cEOS) | Topology create → controller config push verified; admin-group applied and removed on interface; UNICAST-DRAINED alias and `exclude 1` verified on all devices; unicast-drained link excluded from all constrained topologies but present in algo-0; link tagging exclude list verified; flex-algo 128 topology includes only UNICAST-DEFAULT links; `system-colored-tunnel-rib` populated; VPN unicast resolves via color-1 tunnel; per-tenant single and multi-topology stamping verified; topology fallback verified; community stamping per-device verified; stamping exclude verified; multicast RPF unchanged; new topology creation → node-segment lines appear on all devices; topology clear removes admin-groups; full revert on `enabled: false` verified | --- ## Security Considerations - `link_topologies` MUST only be writable by foundation keys. A contributor MUST NOT be able to tag their own link with a topology to influence path steering. The check mirrors the existing pattern used for `link.status` foundation-override. +- `unicast_drained` is contributor-writable by design — contributors may drain their own links as a capacity management tool, consistent with soft-drain. A contributor draining their link removes it from all constrained unicast topologies for all tenants; this is an accepted operational risk and mirrors existing contributor control over link availability. - `TopologyInfo` accounts MUST only be created, updated, or deleted by foundation keys. - The controller feature config file (`features.yaml`) is a local file on the controller host. Access to this file SHOULD be restricted to the operator running the controller. An attacker with write access to the config file could enable or disable flex-algo config or manipulate the stamping allowlist without an onchain transaction. - If a foundation key is compromised, an attacker could reclassify links or create new topology definitions, causing traffic to be steered onto unintended paths. This is the same threat surface as other foundation-key-controlled fields. No new mitigations are introduced. @@ -688,9 +696,7 @@ Attempting to activate a link between steps 1 and 2 will fail with a clear error ## Backward Compatibility -- `link_topologies` appends to the serialized layout and defaults to an empty vector on existing accounts. The onchain schema requires no migration; existing links MUST be tagged with UNICAST-DEFAULT via the `doublezero-admin` migration command as part of the rollout. - Devices that do not receive updated config from the controller MUST continue to forward using IS-IS algo 0 only. The flex-algo topology is distributed — a device that does not participate is simply not included in the constrained SPF. -- The controller MUST push `system-colored-tunnel-rib` config and flex-algo definitions before activating per-VRF route-maps. Activating route-maps first causes VPN routes to carry a color community with no matching tunnel RIB entry, leaving them unresolved. The template section order enforces this within a single reconciliation cycle. - The BGP next-hop resolution profile is applied globally. Per-tenant resolution profile overrides are not supported at this stage. --- From 6cdcff8c7fce0f988ff84aff096a0b1f9719f668 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 31 Mar 2026 11:33:05 -0500 Subject: [PATCH 06/49] =?UTF-8?q?rfc18:=20remove=20topology=20update=20com?= =?UTF-8?q?mand=20=E2=80=94=20all=20fields=20immutable=20after=20creation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- rfcs/rfc18-link-classification-flex-algo.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/rfcs/rfc18-link-classification-flex-algo.md b/rfcs/rfc18-link-classification-flex-algo.md index e5513bdcd2..6bff622f3f 100644 --- a/rfcs/rfc18-link-classification-flex-algo.md +++ b/rfcs/rfc18-link-classification-flex-algo.md @@ -161,14 +161,12 @@ if globalstate.foundation_allowlist.contains(payer_account.key) { ``` doublezero link topology create --name --constraint -doublezero link topology update --name doublezero link topology delete --name doublezero link topology clear --name doublezero link topology list ``` -- `create` — creates a `TopologyInfo` PDA; allocates the lowest available admin-group bit from the `AdminGroupBits` `ResourceExtension`; derives and stores flex-algo number; stores the specified constraint (`include-any` or `exclude`). MUST fail if the name already exists. The `AdminGroupBits` bitmap is pre-marked at program initialization to reserve the UNICAST-DRAINED bit, ensuring it is never allocated to a user topology. Device impact is controlled entirely by the controller feature config — no device config is generated until `flex_algo.enabled: true` is set in the config file. -- `update` — reserved for future use; all fields are immutable after creation. No device config change. +- `create` — creates a `TopologyInfo` PDA; allocates the lowest available admin-group bit from the `AdminGroupBits` `ResourceExtension`; derives and stores flex-algo number; stores the specified constraint (`include-any` or `exclude`). All fields are immutable after creation. MUST fail if the name already exists. The `AdminGroupBits` bitmap is pre-marked at program initialization to reserve the UNICAST-DRAINED bit, ensuring it is never allocated to a user topology. Device impact is controlled entirely by the controller feature config — no device config is generated until `flex_algo.enabled: true` is set in the config file. - `delete` — removes the `TopologyInfo` PDA onchain. MUST fail if any link still references this topology (use `clear` first). On the next reconciliation cycle, the controller removes the deleted topology's admin-group alias and flex-algo definition from all devices. Admin-group bits from deleted topologies MUST NOT be reused — the `AdminGroupBits` `ResourceExtension` bitmap persists allocated bits permanently. - `clear` — removes this topology from all links currently assigned to it, setting `link_topologies` to an empty vector on each. This is a multi-transaction sweep — one `LinkUpdateArgs` instruction is submitted per assigned link; it is not atomic. If the sweep fails partway through, the operator MUST re-run `clear`; the operation is idempotent and will only submit instructions for links that still reference the topology. The `delete` guard (which rejects if any link still references the topology) is the safety net — partial completion is safe because a re-run will clear the remaining references before deletion is attempted. On the next reconciliation cycle, the controller re-applies only the remaining topologies on each affected interface — if other topologies remain, `traffic-engineering administrative-group ` is applied; if no topologies remain, `no traffic-engineering administrative-group` is applied. - `list` — fetches all `TopologyInfo` accounts and all `Link` accounts and groups links by topology. SHOULD emit a warning if any topology has fewer links tagged than the minimum required for a connected topology. From c64dee202d62769a55308a81e385face5af8affd Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 31 Mar 2026 15:57:42 -0500 Subject: [PATCH 07/49] smartcontract: add AdminGroupBits ResourceExtension with UNICAST-DRAINED pre-mark --- .../doublezero-serviceability/src/pda.rs | 11 ++- .../src/processors/resource/mod.rs | 10 +++ .../doublezero-serviceability/src/resource.rs | 2 + .../doublezero-serviceability/src/seeds.rs | 1 + .../tests/topology_test.rs | 71 +++++++++++++++++++ 5 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 smartcontract/programs/doublezero-serviceability/tests/topology_test.rs diff --git a/smartcontract/programs/doublezero-serviceability/src/pda.rs b/smartcontract/programs/doublezero-serviceability/src/pda.rs index d661aa6ccc..312097a498 100644 --- a/smartcontract/programs/doublezero-serviceability/src/pda.rs +++ b/smartcontract/programs/doublezero-serviceability/src/pda.rs @@ -4,9 +4,9 @@ use solana_program::pubkey::Pubkey; use crate::{ seeds::{ - SEED_ACCESS_PASS, SEED_CONFIG, SEED_CONTRIBUTOR, SEED_DEVICE, SEED_DEVICE_TUNNEL_BLOCK, - SEED_DZ_PREFIX_BLOCK, SEED_EXCHANGE, SEED_GLOBALSTATE, SEED_LINK, SEED_LINK_IDS, - SEED_LOCATION, SEED_MULTICASTGROUP_BLOCK, SEED_MULTICAST_GROUP, + SEED_ACCESS_PASS, SEED_ADMIN_GROUP_BITS, SEED_CONFIG, SEED_CONTRIBUTOR, SEED_DEVICE, + SEED_DEVICE_TUNNEL_BLOCK, SEED_DZ_PREFIX_BLOCK, SEED_EXCHANGE, SEED_GLOBALSTATE, SEED_LINK, + SEED_LINK_IDS, SEED_LOCATION, SEED_MULTICASTGROUP_BLOCK, SEED_MULTICAST_GROUP, SEED_MULTICAST_PUBLISHER_BLOCK, SEED_PERMISSION, SEED_PREFIX, SEED_PROGRAM_CONFIG, SEED_SEGMENT_ROUTING_IDS, SEED_TENANT, SEED_TUNNEL_IDS, SEED_USER, SEED_USER_TUNNEL_BLOCK, SEED_VRF_IDS, @@ -169,5 +169,10 @@ pub fn get_resource_extension_pda( Pubkey::find_program_address(&[SEED_PREFIX, SEED_VRF_IDS], program_id); (pda, bump_seed, SEED_VRF_IDS) } + crate::resource::ResourceType::AdminGroupBits => { + let (pda, bump_seed) = + Pubkey::find_program_address(&[SEED_PREFIX, SEED_ADMIN_GROUP_BITS], program_id); + (pda, bump_seed, SEED_ADMIN_GROUP_BITS) + } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs index 6838ba8c47..8f9dc19a1f 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs @@ -69,6 +69,7 @@ pub fn get_resource_extension_range( ResourceType::LinkIds => ResourceExtensionRange::IdRange(0, 65535), ResourceType::SegmentRoutingIds => ResourceExtensionRange::IdRange(1, 65535), ResourceType::VrfIds => ResourceExtensionRange::IdRange(1, 1024), + ResourceType::AdminGroupBits => ResourceExtensionRange::IdRange(0, 127), } } @@ -171,6 +172,15 @@ pub fn create_resource( resource.allocate(1)?; // Allocates index 0 } + // Pre-mark bit 1 (UNICAST-DRAINED) so it is never allocated to a user topology. + // IS-IS flex-algo admin-group bit 1 is reserved for the UNICAST-DRAINED topology + // and must never be reused. + if let ResourceType::AdminGroupBits = resource_type { + let mut buffer = resource_account.data.borrow_mut(); + let mut resource = ResourceExtensionBorrowed::inplace_from(&mut buffer[..])?; + resource.allocate_specific(&crate::resource::IdOrIp::Id(1))?; + } + Ok(()) } diff --git a/smartcontract/programs/doublezero-serviceability/src/resource.rs b/smartcontract/programs/doublezero-serviceability/src/resource.rs index 79de501b0b..9b2bdf95b7 100644 --- a/smartcontract/programs/doublezero-serviceability/src/resource.rs +++ b/smartcontract/programs/doublezero-serviceability/src/resource.rs @@ -15,6 +15,7 @@ pub enum ResourceType { LinkIds, SegmentRoutingIds, VrfIds, + AdminGroupBits, } impl fmt::Display for ResourceType { @@ -29,6 +30,7 @@ impl fmt::Display for ResourceType { ResourceType::LinkIds => write!(f, "LinkIds"), ResourceType::SegmentRoutingIds => write!(f, "SegmentRoutingIds"), ResourceType::VrfIds => write!(f, "VrfIds"), + ResourceType::AdminGroupBits => write!(f, "AdminGroupBits"), } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/seeds.rs b/smartcontract/programs/doublezero-serviceability/src/seeds.rs index ed605fde57..4241ac164e 100644 --- a/smartcontract/programs/doublezero-serviceability/src/seeds.rs +++ b/smartcontract/programs/doublezero-serviceability/src/seeds.rs @@ -21,3 +21,4 @@ pub const SEED_LINK_IDS: &[u8] = b"linkids"; pub const SEED_SEGMENT_ROUTING_IDS: &[u8] = b"segmentroutingids"; pub const SEED_VRF_IDS: &[u8] = b"vrfids"; pub const SEED_PERMISSION: &[u8] = b"permission"; +pub const SEED_ADMIN_GROUP_BITS: &[u8] = b"admingroupbits"; diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs new file mode 100644 index 0000000000..d177e25ea7 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -0,0 +1,71 @@ +//! Integration tests for AdminGroupBits ResourceExtension (RFC-18 / Link Classification). + +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::get_resource_extension_pda, + processors::resource::create::ResourceCreateArgs, + resource::{IdOrIp, ResourceType}, +}; +use solana_program_test::*; +use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey}; + +mod test_helpers; +use test_helpers::*; + +#[tokio::test] +async fn test_admin_group_bits_create_and_pre_mark() { + println!("[TEST] test_admin_group_bits_create_and_pre_mark"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let (resource_pubkey, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + // Create the AdminGroupBits resource extension + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateResource(ResourceCreateArgs { + resource_type: ResourceType::AdminGroupBits, + }), + vec![ + AccountMeta::new(resource_pubkey, false), + AccountMeta::new(Pubkey::default(), false), // associated_account (not used) + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + ], + &payer, + ) + .await; + + // Verify the account was created and has data + let account = banks_client + .get_account(resource_pubkey) + .await + .unwrap() + .expect("AdminGroupBits account should exist"); + + assert!( + !account.data.is_empty(), + "AdminGroupBits account should have non-empty data" + ); + + // Verify bit 1 (UNICAST-DRAINED) is pre-marked + let resource = get_resource_extension_data(&mut banks_client, resource_pubkey) + .await + .expect("AdminGroupBits resource extension should be deserializable"); + + let allocated = resource.iter_allocated(); + assert_eq!(allocated.len(), 1, "exactly one bit should be pre-marked"); + assert_eq!( + allocated[0], + IdOrIp::Id(1), + "bit 1 (UNICAST-DRAINED) should be pre-marked" + ); + + println!("[PASS] test_admin_group_bits_create_and_pre_mark"); +} From 3d1ceafcfd5db0f3b348f6f0460b8bcd6caaf915 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 31 Mar 2026 16:16:15 -0500 Subject: [PATCH 08/49] smartcontract: add TopologyInfo, FlexAlgoNodeSegment state structs; InterfaceV3 --- client/doublezero/src/dzd_latency.rs | 3 +- .../doublezero-serviceability/src/pda.rs | 8 +- .../src/processors/device/interface/create.rs | 1 + .../doublezero-serviceability/src/seeds.rs | 1 + .../src/state/accountdata.rs | 8 +- .../src/state/accounttype.rs | 3 + .../src/state/device.rs | 3 + .../src/state/interface.rs | 125 +++++++++++++++++- .../src/state/mod.rs | 1 + .../src/state/topology.rs | 68 ++++++++++ .../tests/topology_test.rs | 43 +++++- 11 files changed, 254 insertions(+), 10 deletions(-) create mode 100644 smartcontract/programs/doublezero-serviceability/src/state/topology.rs diff --git a/client/doublezero/src/dzd_latency.rs b/client/doublezero/src/dzd_latency.rs index 3b4e09fcf5..cec4b93214 100644 --- a/client/doublezero/src/dzd_latency.rs +++ b/client/doublezero/src/dzd_latency.rs @@ -259,7 +259,7 @@ mod tests { .into_iter() .enumerate() .map(|(i, ip)| { - Interface::V2(CurrentInterfaceVersion { + Interface::V3(CurrentInterfaceVersion { status: InterfaceStatus::Activated, name: format!("Loopback{}", i), interface_type: InterfaceType::Loopback, @@ -274,6 +274,7 @@ mod tests { ip_net: NetworkV4::new(ip, 32).unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], }) }) .collect(); diff --git a/smartcontract/programs/doublezero-serviceability/src/pda.rs b/smartcontract/programs/doublezero-serviceability/src/pda.rs index 312097a498..c3e9892c27 100644 --- a/smartcontract/programs/doublezero-serviceability/src/pda.rs +++ b/smartcontract/programs/doublezero-serviceability/src/pda.rs @@ -8,8 +8,8 @@ use crate::{ SEED_DEVICE_TUNNEL_BLOCK, SEED_DZ_PREFIX_BLOCK, SEED_EXCHANGE, SEED_GLOBALSTATE, SEED_LINK, SEED_LINK_IDS, SEED_LOCATION, SEED_MULTICASTGROUP_BLOCK, SEED_MULTICAST_GROUP, SEED_MULTICAST_PUBLISHER_BLOCK, SEED_PERMISSION, SEED_PREFIX, SEED_PROGRAM_CONFIG, - SEED_SEGMENT_ROUTING_IDS, SEED_TENANT, SEED_TUNNEL_IDS, SEED_USER, SEED_USER_TUNNEL_BLOCK, - SEED_VRF_IDS, + SEED_SEGMENT_ROUTING_IDS, SEED_TENANT, SEED_TOPOLOGY, SEED_TUNNEL_IDS, SEED_USER, + SEED_USER_TUNNEL_BLOCK, SEED_VRF_IDS, }, state::user::UserType, }; @@ -103,6 +103,10 @@ pub fn get_accesspass_pda( ) } +pub fn get_topology_pda(program_id: &Pubkey, name: &str) -> (Pubkey, u8) { + Pubkey::find_program_address(&[SEED_PREFIX, SEED_TOPOLOGY, name.as_bytes()], program_id) +} + pub fn get_resource_extension_pda( program_id: &Pubkey, resource_type: crate::resource::ResourceType, diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/create.rs index 271fe8abb3..4df55ea6f9 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/create.rs @@ -222,6 +222,7 @@ pub fn process_create_device_interface( ip_net, node_segment_idx, user_tunnel_endpoint: value.user_tunnel_endpoint, + flex_algo_node_segments: vec![], } .to_interface(), ); diff --git a/smartcontract/programs/doublezero-serviceability/src/seeds.rs b/smartcontract/programs/doublezero-serviceability/src/seeds.rs index 4241ac164e..66f3d926ec 100644 --- a/smartcontract/programs/doublezero-serviceability/src/seeds.rs +++ b/smartcontract/programs/doublezero-serviceability/src/seeds.rs @@ -22,3 +22,4 @@ pub const SEED_SEGMENT_ROUTING_IDS: &[u8] = b"segmentroutingids"; pub const SEED_VRF_IDS: &[u8] = b"vrfids"; pub const SEED_PERMISSION: &[u8] = b"permission"; pub const SEED_ADMIN_GROUP_BITS: &[u8] = b"admingroupbits"; +pub const SEED_TOPOLOGY: &[u8] = b"topology"; diff --git a/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs b/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs index ac9c67a82c..c70826a105 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs @@ -5,7 +5,7 @@ use crate::{ exchange::Exchange, globalconfig::GlobalConfig, globalstate::GlobalState, link::Link, location::Location, multicastgroup::MulticastGroup, permission::Permission, programconfig::ProgramConfig, resource_extension::ResourceExtensionOwned, tenant::Tenant, - user::User, + topology::TopologyInfo, user::User, }, }; use solana_program::program_error::ProgramError; @@ -29,6 +29,7 @@ pub enum AccountData { ResourceExtension(ResourceExtensionOwned), Tenant(Tenant), Permission(Permission), + Topology(TopologyInfo), } impl AccountData { @@ -49,6 +50,7 @@ impl AccountData { AccountData::ResourceExtension(_) => "ResourceExtension", AccountData::Tenant(_) => "Tenant", AccountData::Permission(_) => "Permission", + AccountData::Topology(_) => "Topology", } } @@ -69,6 +71,7 @@ impl AccountData { AccountData::ResourceExtension(resource_extension) => resource_extension.to_string(), AccountData::Tenant(tenant) => tenant.to_string(), AccountData::Permission(permission) => permission.to_string(), + AccountData::Topology(topology) => topology.to_string(), } } @@ -224,6 +227,9 @@ impl TryFrom<&[u8]> for AccountData { AccountType::Permission => Ok(AccountData::Permission(Permission::try_from( bytes as &[u8], )?)), + AccountType::Topology => Ok(AccountData::Topology(TopologyInfo::try_from( + bytes as &[u8], + )?)), } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/state/accounttype.rs b/smartcontract/programs/doublezero-serviceability/src/state/accounttype.rs index 522bbebd57..d3465ff7c2 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/accounttype.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/accounttype.rs @@ -23,6 +23,7 @@ pub enum AccountType { ResourceExtension = 12, Tenant = 13, Permission = 15, + Topology = 16, } pub trait AccountTypeInfo { @@ -50,6 +51,7 @@ impl From for AccountType { 12 => AccountType::ResourceExtension, 13 => AccountType::Tenant, 15 => AccountType::Permission, + 16 => AccountType::Topology, _ => AccountType::None, } } @@ -73,6 +75,7 @@ impl fmt::Display for AccountType { AccountType::ResourceExtension => write!(f, "resourceextension"), AccountType::Tenant => write!(f, "tenant"), AccountType::Permission => write!(f, "permission"), + AccountType::Topology => write!(f, "topology"), } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/state/device.rs b/smartcontract/programs/doublezero-serviceability/src/state/device.rs index b39bec5be5..e4b6824c74 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/device.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/device.rs @@ -1049,6 +1049,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 42, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface(); let val = Device { @@ -1119,6 +1120,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 42, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface(), CurrentInterfaceVersion { @@ -1136,6 +1138,7 @@ mod tests { ip_net: "10.0.1.1/24".parse().unwrap(), node_segment_idx: 24, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } .to_interface(), ], diff --git a/smartcontract/programs/doublezero-serviceability/src/state/interface.rs b/smartcontract/programs/doublezero-serviceability/src/state/interface.rs index 4187db5d60..0f49c9cac0 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/interface.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/interface.rs @@ -390,6 +390,113 @@ impl Default for InterfaceV2 { } } +#[derive(BorshDeserialize, BorshSerialize, Debug, PartialEq, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct InterfaceV3 { + pub status: InterfaceStatus, // 1 + pub name: String, // 4 + len + pub interface_type: InterfaceType, // 1 + pub interface_cyoa: InterfaceCYOA, // 1 + pub interface_dia: InterfaceDIA, // 1 + pub loopback_type: LoopbackType, // 1 + pub bandwidth: u64, // 8 + pub cir: u64, // 8 + pub mtu: u16, // 2 + pub routing_mode: RoutingMode, // 1 + pub vlan_id: u16, // 2 + pub ip_net: NetworkV4, // 4 IPv4 address + 1 subnet mask + pub node_segment_idx: u16, // 2 + pub user_tunnel_endpoint: bool, // 1 + pub flex_algo_node_segments: Vec, +} + +impl InterfaceV3 { + pub fn size(&self) -> usize { + Self::size_given_name_len(self.name.len()) + } + + pub fn to_interface(&self) -> Interface { + Interface::V3(self.clone()) + } + + pub fn size_given_name_len(name_len: usize) -> usize { + 1 + 4 + name_len + 1 + 1 + 1 + 1 + 8 + 8 + 2 + 1 + 2 + 5 + 2 + 1 + 4 // +4 for empty flex_algo_node_segments vec (Borsh length prefix) + } +} + +impl TryFrom<&[u8]> for InterfaceV3 { + type Error = ProgramError; + + fn try_from(mut data: &[u8]) -> Result { + Ok(Self { + status: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + name: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + interface_type: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + interface_cyoa: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + interface_dia: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + loopback_type: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + bandwidth: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + cir: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + mtu: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + routing_mode: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + vlan_id: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + ip_net: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + node_segment_idx: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + user_tunnel_endpoint: { + let val: u8 = BorshDeserialize::deserialize(&mut data).unwrap_or_default(); + val != 0 + }, + flex_algo_node_segments: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + }) + } +} + +impl TryFrom<&InterfaceV2> for InterfaceV3 { + type Error = ProgramError; + + fn try_from(data: &InterfaceV2) -> Result { + Ok(Self { + status: data.status, + name: data.name.clone(), + interface_type: data.interface_type, + interface_cyoa: data.interface_cyoa, + interface_dia: data.interface_dia, + loopback_type: data.loopback_type, + bandwidth: data.bandwidth, + cir: data.cir, + mtu: data.mtu, + routing_mode: data.routing_mode, + vlan_id: data.vlan_id, + ip_net: data.ip_net, + node_segment_idx: data.node_segment_idx, + user_tunnel_endpoint: data.user_tunnel_endpoint, + flex_algo_node_segments: vec![], + }) + } +} + +impl Default for InterfaceV3 { + fn default() -> Self { + Self { + status: InterfaceStatus::Pending, + name: String::default(), + interface_type: InterfaceType::Invalid, + interface_cyoa: InterfaceCYOA::None, + interface_dia: InterfaceDIA::None, + loopback_type: LoopbackType::None, + bandwidth: 0, + cir: 0, + mtu: 1500, + routing_mode: RoutingMode::Static, + vlan_id: 0, + ip_net: NetworkV4::default(), + node_segment_idx: 0, + user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], + } + } +} + #[repr(u8)] #[derive(BorshDeserialize, BorshSerialize, Debug, PartialEq, Clone)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -397,15 +504,20 @@ impl Default for InterfaceV2 { pub enum Interface { V1(InterfaceV1), V2(InterfaceV2), + V3(InterfaceV3), } -pub type CurrentInterfaceVersion = InterfaceV2; +pub type CurrentInterfaceVersion = InterfaceV3; impl Interface { pub fn into_current_version(&self) -> CurrentInterfaceVersion { match self { - Interface::V1(v1) => v1.try_into().unwrap_or_default(), - Interface::V2(v2) => v2.clone(), + Interface::V1(v1) => { + let v2: InterfaceV2 = v1.try_into().unwrap_or_default(); + InterfaceV3::try_from(&v2).unwrap_or_default() + } + Interface::V2(v2) => InterfaceV3::try_from(v2).unwrap_or_default(), + Interface::V3(v3) => v3.clone(), } } @@ -413,6 +525,7 @@ impl Interface { let base_size = match self { Interface::V1(v1) => v1.size(), Interface::V2(v2) => v2.size(), + Interface::V3(v3) => v3.size(), }; base_size + 1 // +1 for the enum discriminant } @@ -476,8 +589,10 @@ impl TryFrom<&[u8]> for Interface { fn try_from(mut data: &[u8]) -> Result { match BorshDeserialize::deserialize(&mut data) { - Ok(0) => Ok(Interface::V1(InterfaceV1::try_from(data)?)), - _ => Ok(Interface::V1(InterfaceV1::default())), // Default case + Ok(0u8) => Ok(Interface::V1(InterfaceV1::try_from(data)?)), + Ok(1u8) => Ok(Interface::V2(InterfaceV2::try_from(data)?)), + Ok(2u8) => Ok(Interface::V3(InterfaceV3::try_from(data)?)), + _ => Ok(Interface::V3(InterfaceV3::default())), } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/state/mod.rs b/smartcontract/programs/doublezero-serviceability/src/state/mod.rs index 793c35e469..0f7ee35a45 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/mod.rs @@ -15,4 +15,5 @@ pub mod permission; pub mod programconfig; pub mod resource_extension; pub mod tenant; +pub mod topology; pub mod user; diff --git a/smartcontract/programs/doublezero-serviceability/src/state/topology.rs b/smartcontract/programs/doublezero-serviceability/src/state/topology.rs new file mode 100644 index 0000000000..8e02ea58b7 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/state/topology.rs @@ -0,0 +1,68 @@ +use crate::state::accounttype::AccountType; +use borsh::{BorshDeserialize, BorshSerialize}; +use solana_program::pubkey::Pubkey; + +#[repr(u8)] +#[derive(BorshSerialize, BorshDeserialize, Debug, Clone, Copy, PartialEq, Default)] +#[borsh(use_discriminant = true)] +pub enum TopologyConstraint { + #[default] + IncludeAny = 0, + Exclude = 1, +} + +#[derive(BorshSerialize, BorshDeserialize, Debug, Clone, PartialEq)] +pub struct TopologyInfo { + pub account_type: AccountType, + pub owner: Pubkey, + pub bump_seed: u8, + pub name: String, // max 32 bytes enforced on create + pub admin_group_bit: u8, // 0–127 + pub flex_algo_number: u8, // always 128 + admin_group_bit + pub constraint: TopologyConstraint, +} + +impl std::fmt::Display for TopologyInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "name={} bit={} algo={} color={} constraint={:?}", + self.name, + self.admin_group_bit, + self.flex_algo_number, + self.admin_group_bit as u16 + 1, + self.constraint + ) + } +} + +impl TryFrom<&[u8]> for TopologyInfo { + type Error = solana_program::program_error::ProgramError; + + fn try_from(mut data: &[u8]) -> Result { + Ok(Self { + account_type: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + owner: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + bump_seed: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + name: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + admin_group_bit: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + flex_algo_number: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + constraint: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + }) + } +} + +impl TryFrom<&solana_program::account_info::AccountInfo<'_>> for TopologyInfo { + type Error = solana_program::program_error::ProgramError; + + fn try_from(account: &solana_program::account_info::AccountInfo) -> Result { + Self::try_from(&account.data.borrow()[..]) + } +} + +/// Flex-algo node segment entry on a Vpnv4 loopback Interface account. +#[derive(BorshSerialize, BorshDeserialize, Debug, Clone, PartialEq)] +pub struct FlexAlgoNodeSegment { + pub topology: Pubkey, // TopologyInfo PDA pubkey + pub node_segment_idx: u16, // allocated from SegmentRoutingIds ResourceExtension +} diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index d177e25ea7..1b637510ba 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -1,4 +1,4 @@ -//! Integration tests for AdminGroupBits ResourceExtension (RFC-18 / Link Classification). +//! Tests for TopologyInfo, FlexAlgoNodeSegment, and InterfaceV3 (RFC-18 / Link Classification). use doublezero_serviceability::{ instructions::DoubleZeroInstruction, @@ -69,3 +69,44 @@ async fn test_admin_group_bits_create_and_pre_mark() { println!("[PASS] test_admin_group_bits_create_and_pre_mark"); } + +#[test] +fn test_topology_info_roundtrip() { + use doublezero_serviceability::state::{ + accounttype::AccountType, + topology::{TopologyConstraint, TopologyInfo}, + }; + + let info = TopologyInfo { + account_type: AccountType::Topology, + owner: solana_sdk::pubkey::Pubkey::new_unique(), + bump_seed: 42, + name: "unicast-default".to_string(), + admin_group_bit: 0, + flex_algo_number: 128, + constraint: TopologyConstraint::IncludeAny, + }; + let bytes = borsh::to_vec(&info).unwrap(); + let decoded = TopologyInfo::try_from(bytes.as_slice()).unwrap(); + assert_eq!(decoded, info); +} + +#[test] +fn test_flex_algo_node_segment_roundtrip() { + use doublezero_serviceability::state::topology::FlexAlgoNodeSegment; + + let seg = FlexAlgoNodeSegment { + topology: solana_sdk::pubkey::Pubkey::new_unique(), + node_segment_idx: 1001, + }; + let bytes = borsh::to_vec(&seg).unwrap(); + let decoded: FlexAlgoNodeSegment = borsh::from_slice(&bytes).unwrap(); + assert_eq!(decoded.node_segment_idx, 1001); +} + +#[test] +fn test_interface_v3_defaults_flex_algo_node_segments_empty() { + use doublezero_serviceability::state::interface::InterfaceV3; + let iface = InterfaceV3::default(); + assert!(iface.flex_algo_node_segments.is_empty()); +} From 393b89a29ab80e1b42eff7d7147619ae3943b08b Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 31 Mar 2026 16:57:56 -0500 Subject: [PATCH 09/49] smartcontract: add TopologyCreate instruction with admin-group bit allocation Implements the TopologyCreate instruction (variant 104) for the doublezero-serviceability program. The instruction creates a TopologyInfo PDA, allocates the lowest available bit from the AdminGroupBits ResourceExtension (skipping pre-reserved bit 1 / UNICAST-DRAINED), derives flex_algo_number = 128 + admin_group_bit, and optionally backfills Vpnv4 loopback interfaces on Device accounts with a FlexAlgoNodeSegment entry. Also adds stub TopologyDelete (105) and TopologyClear (106) instructions for future implementation, and fixes missing flex_algo_node_segments field in CLI test fixtures for InterfaceV3. --- .../cli/src/device/interface/create.rs | 2 + .../cli/src/device/interface/delete.rs | 2 + smartcontract/cli/src/device/interface/get.rs | 1 + .../cli/src/device/interface/list.rs | 2 + .../cli/src/device/interface/update.rs | 5 + .../src/entrypoint.rs | 13 + .../src/instructions.rs | 35 + .../src/processors/mod.rs | 1 + .../src/processors/topology/clear.rs | 16 + .../src/processors/topology/create.rs | 199 ++++++ .../src/processors/topology/delete.rs | 16 + .../src/processors/topology/mod.rs | 3 + .../src/state/accountdata.rs | 8 + .../src/state/topology.rs | 17 +- .../tests/topology_test.rs | 650 +++++++++++++++++- 15 files changed, 961 insertions(+), 9 deletions(-) create mode 100644 smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs create mode 100644 smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs create mode 100644 smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs create mode 100644 smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs diff --git a/smartcontract/cli/src/device/interface/create.rs b/smartcontract/cli/src/device/interface/create.rs index fdca02b3b4..cb22801826 100644 --- a/smartcontract/cli/src/device/interface/create.rs +++ b/smartcontract/cli/src/device/interface/create.rs @@ -223,6 +223,7 @@ mod tests { ip_net: "185.189.47.80/32".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } .to_interface()], max_users: 255, @@ -321,6 +322,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface()], max_users: 255, diff --git a/smartcontract/cli/src/device/interface/delete.rs b/smartcontract/cli/src/device/interface/delete.rs index 8b7da6941c..f3345703b4 100644 --- a/smartcontract/cli/src/device/interface/delete.rs +++ b/smartcontract/cli/src/device/interface/delete.rs @@ -109,6 +109,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 12, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface(), CurrentInterfaceVersion { @@ -126,6 +127,7 @@ mod tests { ip_net: "10.0.1.1/24".parse().unwrap(), node_segment_idx: 13, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } .to_interface(), ], diff --git a/smartcontract/cli/src/device/interface/get.rs b/smartcontract/cli/src/device/interface/get.rs index 7c415b903d..efe4786b73 100644 --- a/smartcontract/cli/src/device/interface/get.rs +++ b/smartcontract/cli/src/device/interface/get.rs @@ -136,6 +136,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 42, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface()], max_users: 255, diff --git a/smartcontract/cli/src/device/interface/list.rs b/smartcontract/cli/src/device/interface/list.rs index fdf977f844..2922b214d5 100644 --- a/smartcontract/cli/src/device/interface/list.rs +++ b/smartcontract/cli/src/device/interface/list.rs @@ -164,6 +164,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 12, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface(), CurrentInterfaceVersion { @@ -182,6 +183,7 @@ mod tests { ip_net: "10.0.1.1/24".parse().unwrap(), node_segment_idx: 13, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } .to_interface(), ], diff --git a/smartcontract/cli/src/device/interface/update.rs b/smartcontract/cli/src/device/interface/update.rs index 0039786209..b3942b632d 100644 --- a/smartcontract/cli/src/device/interface/update.rs +++ b/smartcontract/cli/src/device/interface/update.rs @@ -219,6 +219,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface(), CurrentInterfaceVersion { @@ -236,6 +237,7 @@ mod tests { ip_net: "10.0.1.1/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } .to_interface(), ], @@ -353,6 +355,7 @@ mod tests { ip_net: "10.0.0.1/32".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } .to_interface()], max_users: 255, @@ -401,6 +404,7 @@ mod tests { ip_net: "185.189.47.80/32".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } .to_interface()], max_users: 255, @@ -497,6 +501,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface()], max_users: 255, diff --git a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs index 02760eeacc..ef4313121b 100644 --- a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs +++ b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs @@ -96,6 +96,10 @@ use crate::{ remove_administrator::process_remove_administrator_tenant, update::process_update_tenant, update_payment_status::process_update_payment_status, }, + topology::{ + clear::process_topology_clear, create::process_topology_create, + delete::process_topology_delete, + }, user::{ activate::process_activate_user, ban::process_ban_user, check_access_pass::process_check_access_pass_user, @@ -421,6 +425,15 @@ pub fn process_instruction( DoubleZeroInstruction::DeletePermission(value) => { process_delete_permission(program_id, accounts, &value)? } + DoubleZeroInstruction::CreateTopology(value) => { + process_topology_create(program_id, accounts, &value)? + } + DoubleZeroInstruction::DeleteTopology(value) => { + process_topology_delete(program_id, accounts, &value)? + } + DoubleZeroInstruction::ClearTopology(value) => { + process_topology_clear(program_id, accounts, &value)? + } }; Ok(()) } diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index e29ff35c7e..e351d3ed4a 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -79,6 +79,7 @@ use crate::processors::{ delete::TenantDeleteArgs, remove_administrator::TenantRemoveAdministratorArgs, update::TenantUpdateArgs, update_payment_status::UpdatePaymentStatusArgs, }, + topology::{clear::TopologyClearArgs, create::TopologyCreateArgs, delete::TopologyDeleteArgs}, user::{ activate::UserActivateArgs, ban::UserBanArgs, check_access_pass::CheckUserAccessPassArgs, closeaccount::UserCloseAccountArgs, create::UserCreateArgs, @@ -218,6 +219,10 @@ pub enum DoubleZeroInstruction { Deprecated102(), // variant 102 (was CreateReservedSubscribeUser) Deprecated103(), // variant 103 (was DeleteReservedSubscribeUser) + + CreateTopology(TopologyCreateArgs), // variant 104 + DeleteTopology(TopologyDeleteArgs), // variant 105 + ClearTopology(TopologyClearArgs), // variant 106 } impl DoubleZeroInstruction { @@ -349,6 +354,9 @@ impl DoubleZeroInstruction { 100 => Ok(Self::ResumePermission(PermissionResumeArgs::try_from(rest).unwrap())), 101 => Ok(Self::DeletePermission(PermissionDeleteArgs::try_from(rest).unwrap())), + 104 => Ok(Self::CreateTopology(TopologyCreateArgs::try_from(rest).unwrap())), + 105 => Ok(Self::DeleteTopology(TopologyDeleteArgs::try_from(rest).unwrap())), + 106 => Ok(Self::ClearTopology(TopologyClearArgs::try_from(rest).unwrap())), _ => Err(ProgramError::InvalidInstructionData), } @@ -483,6 +491,10 @@ impl DoubleZeroInstruction { Self::Deprecated102() => "Deprecated102".to_string(), Self::Deprecated103() => "Deprecated103".to_string(), + + Self::CreateTopology(_) => "CreateTopology".to_string(), // variant 104 + Self::DeleteTopology(_) => "DeleteTopology".to_string(), // variant 105 + Self::ClearTopology(_) => "ClearTopology".to_string(), // variant 106 } } @@ -609,6 +621,10 @@ impl DoubleZeroInstruction { Self::Deprecated102() => String::new(), Self::Deprecated103() => String::new(), + + Self::CreateTopology(args) => format!("{args:?}"), // variant 104 + Self::DeleteTopology(args) => format!("{args:?}"), // variant 105 + Self::ClearTopology(args) => format!("{args:?}"), // variant 106 } } } @@ -1298,5 +1314,24 @@ mod tests { DoubleZeroInstruction::DeletePermission(PermissionDeleteArgs {}), "DeletePermission", ); + test_instruction( + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unicast-default".to_string(), + constraint: crate::state::topology::TopologyConstraint::IncludeAny, + }), + "CreateTopology", + ); + test_instruction( + DoubleZeroInstruction::DeleteTopology(TopologyDeleteArgs { + name: "unicast-default".to_string(), + }), + "DeleteTopology", + ); + test_instruction( + DoubleZeroInstruction::ClearTopology(TopologyClearArgs { + name: "unicast-default".to_string(), + }), + "ClearTopology", + ); } } diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/mod.rs index a148f5c660..c4991c43dc 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/mod.rs @@ -12,5 +12,6 @@ pub mod multicastgroup; pub mod permission; pub mod resource; pub mod tenant; +pub mod topology; pub mod user; pub mod validation; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs new file mode 100644 index 0000000000..12a533c238 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs @@ -0,0 +1,16 @@ +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use solana_program::{account_info::AccountInfo, entrypoint::ProgramResult, pubkey::Pubkey}; + +#[derive(BorshSerialize, BorshDeserializeIncremental, Debug, Clone, PartialEq)] +pub struct TopologyClearArgs { + pub name: String, +} + +pub fn process_topology_clear( + _program_id: &Pubkey, + _accounts: &[AccountInfo], + _value: &TopologyClearArgs, +) -> ProgramResult { + todo!("TopologyClear not yet implemented") +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs new file mode 100644 index 0000000000..48c7263cee --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs @@ -0,0 +1,199 @@ +use crate::{ + error::DoubleZeroError, + pda::get_topology_pda, + processors::resource::allocate_id, + resource::ResourceType, + seeds::{SEED_PREFIX, SEED_TOPOLOGY}, + serializer::{try_acc_create, try_acc_write}, + state::{ + accounttype::AccountType, + device::Device, + globalstate::GlobalState, + interface::{Interface, LoopbackType}, + topology::{FlexAlgoNodeSegment, TopologyConstraint, TopologyInfo}, + }, +}; +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + program_error::ProgramError, + pubkey::Pubkey, +}; + +pub const MAX_TOPOLOGY_NAME_LEN: usize = 32; + +#[derive(BorshSerialize, BorshDeserializeIncremental, Debug, Clone, PartialEq)] +pub struct TopologyCreateArgs { + pub name: String, + pub constraint: TopologyConstraint, +} + +/// Accounts layout: +/// [0] topology PDA (writable, to be created) +/// [1] admin_group_bits (writable, ResourceExtension) +/// [2] globalstate (readonly) +/// [3] payer (writable, signer, must be in foundation_allowlist) +/// [4] system_program +/// [5] segment_routing_ids (writable, ResourceExtension) — only if Vpnv4 loopbacks passed +/// [6+] Vpnv4 loopback Interface accounts (writable) — optional, for backfill +/// +/// If no Vpnv4 loopbacks are passed, account [5] can be omitted. +pub fn process_topology_create( + program_id: &Pubkey, + accounts: &[AccountInfo], + value: &TopologyCreateArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + let topology_account = next_account_info(accounts_iter)?; + let admin_group_bits_account = next_account_info(accounts_iter)?; + let globalstate_account = next_account_info(accounts_iter)?; + let payer_account = next_account_info(accounts_iter)?; + let system_program = next_account_info(accounts_iter)?; + + assert!(payer_account.is_signer, "Payer account must be a signer"); + + // Authorization: foundation keys only + let globalstate = GlobalState::try_from(&globalstate_account.data.borrow()[..])?; + if !globalstate.foundation_allowlist.contains(payer_account.key) { + msg!("TopologyCreate: unauthorized — foundation key required"); + return Err(DoubleZeroError::Unauthorized.into()); + } + + // Validate name length + if value.name.len() > MAX_TOPOLOGY_NAME_LEN { + msg!( + "TopologyCreate: name exceeds {} bytes", + MAX_TOPOLOGY_NAME_LEN + ); + return Err(DoubleZeroError::InvalidArgument.into()); + } + + // Validate and verify topology PDA + let (expected_pda, bump_seed) = get_topology_pda(program_id, &value.name); + assert_eq!( + topology_account.key, &expected_pda, + "TopologyCreate: invalid topology PDA for name '{}'", + value.name + ); + + if !topology_account.data_is_empty() { + msg!("TopologyCreate: topology '{}' already exists", value.name); + return Err(ProgramError::AccountAlreadyInitialized); + } + + // Validate AdminGroupBits resource account + assert_eq!( + admin_group_bits_account.owner, program_id, + "TopologyCreate: invalid AdminGroupBits account owner" + ); + + // Allocate admin_group_bit (lowest available; bit 1 is pre-marked = never returned) + let admin_group_bit_u16 = allocate_id(admin_group_bits_account)?; + if admin_group_bit_u16 > 127 { + msg!("TopologyCreate: AdminGroupBits exhausted (max 128 topologies)"); + return Err(DoubleZeroError::AllocationFailed.into()); + } + let admin_group_bit = admin_group_bit_u16 as u8; + let flex_algo_number = 128u8 + .checked_add(admin_group_bit) + .ok_or(DoubleZeroError::ArithmeticOverflow)?; + + // Create the topology PDA account + let topology = TopologyInfo { + account_type: AccountType::Topology, + owner: *payer_account.key, + bump_seed, + name: value.name.clone(), + admin_group_bit, + flex_algo_number, + constraint: value.constraint, + }; + + try_acc_create( + &topology, + topology_account, + payer_account, + system_program, + program_id, + &[ + SEED_PREFIX, + SEED_TOPOLOGY, + value.name.as_bytes(), + &[bump_seed], + ], + )?; + + // Backfill Vpnv4 loopbacks (remaining accounts after system_program) + // Convention: if any Device accounts are passed, segment_routing_ids must be + // the last account; Device accounts precede it. + let remaining: Vec<&AccountInfo> = accounts_iter.collect(); + if !remaining.is_empty() { + let (device_accounts, tail) = remaining.split_at(remaining.len() - 1); + let segment_routing_ids_account = tail[0]; + + // Validate the SegmentRoutingIds account + let (expected_sr_pda, _, _) = + crate::pda::get_resource_extension_pda(program_id, ResourceType::SegmentRoutingIds); + assert_eq!( + segment_routing_ids_account.key, &expected_sr_pda, + "TopologyCreate: invalid SegmentRoutingIds PDA" + ); + + for device_account in device_accounts { + if device_account.owner != program_id { + continue; + } + let mut device = Device::try_from(&device_account.data.borrow()[..])?; + let mut modified = false; + for iface in device.interfaces.iter_mut() { + let iface_v3 = iface.into_current_version(); + if iface_v3.loopback_type != LoopbackType::Vpnv4 { + continue; + } + // Skip if already has a segment for this topology (idempotent) + if iface_v3 + .flex_algo_node_segments + .iter() + .any(|s| &s.topology == topology_account.key) + { + continue; + } + let node_segment_idx = allocate_id(segment_routing_ids_account)?; + // Mutate the interface in place — we need to upgrade to V3 if needed + match iface { + Interface::V3(ref mut v3) => { + v3.flex_algo_node_segments.push(FlexAlgoNodeSegment { + topology: *topology_account.key, + node_segment_idx, + }); + } + _ => { + // Upgrade to V3 with the segment added + let mut upgraded = iface.into_current_version(); + upgraded.flex_algo_node_segments.push(FlexAlgoNodeSegment { + topology: *topology_account.key, + node_segment_idx, + }); + *iface = Interface::V3(upgraded); + } + } + modified = true; + } + if modified { + try_acc_write(&device, device_account, payer_account, accounts)?; + } + } + } + + msg!( + "TopologyCreate: created '{}' bit={} algo={} constraint={:?}", + value.name, + admin_group_bit, + flex_algo_number, + value.constraint + ); + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs new file mode 100644 index 0000000000..e9e30cb711 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs @@ -0,0 +1,16 @@ +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use solana_program::{account_info::AccountInfo, entrypoint::ProgramResult, pubkey::Pubkey}; + +#[derive(BorshSerialize, BorshDeserializeIncremental, Debug, Clone, PartialEq)] +pub struct TopologyDeleteArgs { + pub name: String, +} + +pub fn process_topology_delete( + _program_id: &Pubkey, + _accounts: &[AccountInfo], + _value: &TopologyDeleteArgs, +) -> ProgramResult { + todo!("TopologyDelete not yet implemented") +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs new file mode 100644 index 0000000000..52a0eb0975 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs @@ -0,0 +1,3 @@ +pub mod clear; +pub mod create; +pub mod delete; diff --git a/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs b/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs index c70826a105..bc55eac7c1 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs @@ -186,6 +186,14 @@ impl AccountData { Err(DoubleZeroError::InvalidAccountType) } } + + pub fn get_topology(&self) -> Result { + if let AccountData::Topology(topology) = self { + Ok(topology.clone()) + } else { + Err(DoubleZeroError::InvalidAccountType) + } + } } impl TryFrom<&[u8]> for AccountData { diff --git a/smartcontract/programs/doublezero-serviceability/src/state/topology.rs b/smartcontract/programs/doublezero-serviceability/src/state/topology.rs index 8e02ea58b7..4db532c685 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/topology.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/topology.rs @@ -1,10 +1,11 @@ -use crate::state::accounttype::AccountType; +use crate::{error::Validate, state::accounttype::AccountType}; use borsh::{BorshDeserialize, BorshSerialize}; use solana_program::pubkey::Pubkey; #[repr(u8)] #[derive(BorshSerialize, BorshDeserialize, Debug, Clone, Copy, PartialEq, Default)] #[borsh(use_discriminant = true)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum TopologyConstraint { #[default] IncludeAny = 0, @@ -12,6 +13,7 @@ pub enum TopologyConstraint { } #[derive(BorshSerialize, BorshDeserialize, Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct TopologyInfo { pub account_type: AccountType, pub owner: Pubkey, @@ -60,8 +62,21 @@ impl TryFrom<&solana_program::account_info::AccountInfo<'_>> for TopologyInfo { } } +impl Validate for TopologyInfo { + fn validate(&self) -> Result<(), crate::error::DoubleZeroError> { + if self.account_type != AccountType::Topology { + return Err(crate::error::DoubleZeroError::InvalidAccountType); + } + if self.name.len() > 32 { + return Err(crate::error::DoubleZeroError::NameTooLong); + } + Ok(()) + } +} + /// Flex-algo node segment entry on a Vpnv4 loopback Interface account. #[derive(BorshSerialize, BorshDeserialize, Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct FlexAlgoNodeSegment { pub topology: Pubkey, // TopologyInfo PDA pubkey pub node_segment_idx: u16, // allocated from SegmentRoutingIds ResourceExtension diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index 1b637510ba..42499190fe 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -2,16 +2,109 @@ use doublezero_serviceability::{ instructions::DoubleZeroInstruction, - pda::get_resource_extension_pda, - processors::resource::create::ResourceCreateArgs, + pda::{ + get_contributor_pda, get_device_pda, get_exchange_pda, get_globalconfig_pda, + get_location_pda, get_resource_extension_pda, get_topology_pda, + }, + processors::{ + contributor::create::ContributorCreateArgs, + device::{ + activate::DeviceActivateArgs, create::DeviceCreateArgs, + interface::create::DeviceInterfaceCreateArgs, + }, + exchange::create::ExchangeCreateArgs, + location::create::LocationCreateArgs, + resource::create::ResourceCreateArgs, + topology::create::TopologyCreateArgs, + }, resource::{IdOrIp, ResourceType}, + state::{ + accounttype::AccountType, + device::{DeviceDesiredStatus, DeviceType}, + interface::{InterfaceCYOA, InterfaceDIA, LoopbackType, RoutingMode}, + topology::{TopologyConstraint, TopologyInfo}, + }, }; +use solana_program::instruction::InstructionError; use solana_program_test::*; -use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey}; +use solana_sdk::{ + instruction::AccountMeta, pubkey::Pubkey, signature::Keypair, signer::Signer, + transaction::TransactionError, +}; mod test_helpers; use test_helpers::*; +/// Creates the AdminGroupBits resource extension. +/// Requires that global state + global config are already initialized. +async fn create_admin_group_bits( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + globalconfig_pubkey: Pubkey, + payer: &Keypair, +) -> Pubkey { + let (resource_pubkey, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateResource(ResourceCreateArgs { + resource_type: ResourceType::AdminGroupBits, + }), + vec![ + AccountMeta::new(resource_pubkey, false), + AccountMeta::new(Pubkey::default(), false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + ], + payer, + ) + .await; + resource_pubkey +} + +/// Helper that creates the topology using the standard account layout. +async fn create_topology( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + admin_group_bits_pda: Pubkey, + name: &str, + constraint: TopologyConstraint, + payer: &Keypair, +) -> Pubkey { + let (topology_pda, _) = get_topology_pda(&program_id, name); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: name.to_string(), + constraint, + }), + vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + payer, + ) + .await; + topology_pda +} + +async fn get_topology(banks_client: &mut BanksClient, pubkey: Pubkey) -> TopologyInfo { + get_account_data(banks_client, pubkey) + .await + .expect("Topology account should exist") + .get_topology() + .expect("Account should be a Topology") +} + #[tokio::test] async fn test_admin_group_bits_create_and_pre_mark() { println!("[TEST] test_admin_group_bits_create_and_pre_mark"); @@ -104,9 +197,550 @@ fn test_flex_algo_node_segment_roundtrip() { assert_eq!(decoded.node_segment_idx, 1001); } -#[test] -fn test_interface_v3_defaults_flex_algo_node_segments_empty() { - use doublezero_serviceability::state::interface::InterfaceV3; - let iface = InterfaceV3::default(); - assert!(iface.flex_algo_node_segments.is_empty()); +// ============================================================================ +// Integration tests for TopologyCreate instruction +// ============================================================================ + +#[tokio::test] +async fn test_topology_create_bit_0_first() { + println!("[TEST] test_topology_create_bit_0_first"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + let topology = get_topology(&mut banks_client, topology_pda).await; + + assert_eq!(topology.account_type, AccountType::Topology); + assert_eq!(topology.name, "unicast-default"); + assert_eq!(topology.admin_group_bit, 0); + assert_eq!(topology.flex_algo_number, 128); + assert_eq!(topology.constraint, TopologyConstraint::IncludeAny); + + println!("[PASS] test_topology_create_bit_0_first"); +} + +#[tokio::test] +async fn test_topology_create_second_skips_bit_1() { + println!("[TEST] test_topology_create_second_skips_bit_1"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + // First topology gets bit 0 + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Second topology must skip bit 1 (pre-marked UNICAST-DRAINED) and get bit 2 + let topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "shelby", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + let topology = get_topology(&mut banks_client, topology_pda).await; + + assert_eq!(topology.name, "shelby"); + assert_eq!( + topology.admin_group_bit, 2, + "bit 1 should be skipped (UNICAST-DRAINED)" + ); + assert_eq!(topology.flex_algo_number, 130); + + println!("[PASS] test_topology_create_second_skips_bit_1"); +} + +#[tokio::test] +async fn test_topology_create_non_foundation_rejected() { + println!("[TEST] test_topology_create_non_foundation_rejected"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + // Use a keypair that is NOT in the foundation allowlist + let non_foundation = Keypair::new(); + // Fund the non-foundation keypair so it can sign transactions + transfer( + &mut banks_client, + &payer, + &non_foundation.pubkey(), + 10_000_000, + ) + .await; + + let (topology_pda, _) = get_topology_pda(&program_id, "unauthorized-topology"); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unauthorized-topology".to_string(), + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &non_foundation, + ) + .await; + + // DoubleZeroError::Unauthorized = Custom(22) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(22), + ))) => {} + _ => panic!("Expected Unauthorized error (Custom(22)), got {:?}", result), + } + + println!("[PASS] test_topology_create_non_foundation_rejected"); +} + +#[tokio::test] +async fn test_topology_create_name_too_long_rejected() { + println!("[TEST] test_topology_create_name_too_long_rejected"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + // 33-char name exceeds MAX_TOPOLOGY_NAME_LEN=32 + // We use a dummy pubkey for the topology PDA since the name validation fires + // before the PDA check, and find_program_address panics on seeds > 32 bytes. + let long_name = "a".repeat(33); + let topology_pda = Pubkey::new_unique(); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: long_name, + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // DoubleZeroError::InvalidArgument = Custom(65) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(65), + ))) => {} + _ => panic!( + "Expected InvalidArgument error (Custom(65)), got {:?}", + result + ), + } + + println!("[PASS] test_topology_create_name_too_long_rejected"); +} + +#[tokio::test] +async fn test_topology_create_duplicate_rejected() { + println!("[TEST] test_topology_create_duplicate_rejected"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + // First creation succeeds + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Second creation of same name must fail. + // Wait for a new blockhash to avoid transaction deduplication in the test environment. + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // ProgramError::AccountAlreadyInitialized maps to InstructionError::AccountAlreadyInitialized + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::AccountAlreadyInitialized, + ))) => {} + _ => panic!("Expected AccountAlreadyInitialized error, got {:?}", result), + } + + println!("[PASS] test_topology_create_duplicate_rejected"); +} + +#[tokio::test] +async fn test_topology_create_backfills_vpnv4_loopbacks() { + println!("[TEST] test_topology_create_backfills_vpnv4_loopbacks"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + // Create AdminGroupBits and SegmentRoutingIds resources + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); + + // Set up a full device with a Vpnv4 loopback interface + // Step 1: Create Location + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (location_pubkey, _) = get_location_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateLocation(LocationCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + country: "us".to_string(), + lat: 1.234, + lng: 4.567, + loc_id: 0, + }), + vec![ + AccountMeta::new(location_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 2: Create Exchange + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (exchange_pubkey, _) = get_exchange_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateExchange(ExchangeCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + lat: 1.234, + lng: 4.567, + reserved: 0, + }), + vec![ + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 3: Create Contributor + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (contributor_pubkey, _) = + get_contributor_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateContributor(ContributorCreateArgs { + code: "cont".to_string(), + }), + vec![ + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(payer.pubkey(), false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 4: Create Device + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (device_pubkey, _) = get_device_pda(&program_id, globalstate_account.account_index + 1); + let (tunnel_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::TunnelIds(device_pubkey, 0)); + let (dz_prefix_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::DzPrefixBlock(device_pubkey, 0)); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDevice(DeviceCreateArgs { + code: "dz1".to_string(), + device_type: DeviceType::Hybrid, + public_ip: [8, 8, 8, 8].into(), + dz_prefixes: "110.1.0.0/23".parse().unwrap(), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "mgmt".to_string(), + desired_status: Some(DeviceDesiredStatus::Activated), + resource_count: 0, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 5: Activate Device + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateDevice(DeviceActivateArgs { resource_count: 2 }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(tunnel_ids_pda, false), + AccountMeta::new(dz_prefix_pda, false), + ], + &payer, + ) + .await; + + // Step 6: Create a Vpnv4 loopback interface (without onchain allocation — backfill assigns the segment) + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDeviceInterface(DeviceInterfaceCreateArgs { + name: "loopback0".to_string(), + interface_dia: InterfaceDIA::None, + loopback_type: LoopbackType::Vpnv4, + interface_cyoa: InterfaceCYOA::None, + bandwidth: 0, + cir: 0, + ip_net: None, + mtu: 1500, + routing_mode: RoutingMode::Static, + vlan_id: 0, + user_tunnel_endpoint: false, + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 7: Create topology passing the Device + SegmentRoutingIds as remaining accounts + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let instruction = DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + }); + let base_accounts = vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]; + let extra_accounts = vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(segment_routing_ids_pda, false), + ]; + let mut tx = create_transaction_with_extra_accounts( + program_id, + &instruction, + &base_accounts, + &payer, + &extra_accounts, + ); + tx.try_sign(&[&payer], recent_blockhash).unwrap(); + banks_client.process_transaction(tx).await.unwrap(); + + // Verify: the Vpnv4 loopback now has a flex_algo_node_segment pointing to the topology + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found"); + let iface = device.interfaces[0].into_current_version(); + assert_eq!( + iface.flex_algo_node_segments.len(), + 1, + "Expected one flex_algo_node_segment after backfill" + ); + assert_eq!( + iface.flex_algo_node_segments[0].topology, topology_pda, + "Segment should point to the newly created topology" + ); + + // Step 8: Call TopologyCreate again with same device — idempotent, no duplicate segment + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + // Create a second topology so we get a different PDA but still exercise idempotency + // by passing the device again — the first topology's segment must not be duplicated. + // Instead, verify idempotency by calling CreateTopology with the same device a second time + // using a different topology name, then checking the device has exactly two segments (not three). + let (topology2_pda, _) = get_topology_pda(&program_id, "unicast-secondary"); + let instruction2 = DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unicast-secondary".to_string(), + constraint: TopologyConstraint::IncludeAny, + }); + let base_accounts2 = vec![ + AccountMeta::new(topology2_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]; + let mut tx2 = create_transaction_with_extra_accounts( + program_id, + &instruction2, + &base_accounts2, + &payer, + &extra_accounts, + ); + tx2.try_sign(&[&payer], recent_blockhash).unwrap(); + banks_client.process_transaction(tx2).await.unwrap(); + + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found after second topology"); + let iface = device.interfaces[0].into_current_version(); + assert_eq!( + iface.flex_algo_node_segments.len(), + 2, + "Expected two segments after second topology backfill (one per topology)" + ); + + // Step 9: Idempotency — call CreateTopology for unicast-secondary again with the same device. + // The segment for unicast-secondary must not be duplicated. + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + // We need a new topology PDA since unicast-secondary already exists; + // instead use unicast-secondary's PDA but re-create a third topology and pass the device twice. + // Actually the simplest idempotency check: use a third unique topology but re-pass the device — + // after the call, the device should have exactly 3 segments (not more). + // The real idempotency guard is: if we pass a device that already has a segment for topology X, + // a second CreateTopology for X with that device does not add another. We test this by + // calling CreateTopology for topology2 again (which would fail because account already initialized), + // but instead we verify directly: re-run step 8 with the same topology2 already existing — + // the transaction should fail with AccountAlreadyInitialized before the backfill runs. + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + let mut tx_idem = create_transaction_with_extra_accounts( + program_id, + &instruction2, + &base_accounts2, + &payer, + &extra_accounts, + ); + tx_idem.try_sign(&[&payer], recent_blockhash).unwrap(); + let idem_result = banks_client.process_transaction(tx_idem).await; + match idem_result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::AccountAlreadyInitialized, + ))) => {} + _ => panic!( + "Expected AccountAlreadyInitialized on duplicate create, got {:?}", + idem_result + ), + } + + println!("[PASS] test_topology_create_backfills_vpnv4_loopbacks"); } From e73730389254382fe6d32ba3c5871071cc025ac0 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 31 Mar 2026 21:24:24 -0500 Subject: [PATCH 10/49] smartcontract: add TopologyDelete and TopologyClear instructions --- activator/src/process/link.rs | 10 + activator/src/processor.rs | 4 + .../fixtures/generate-fixtures/src/main.rs | 1 + smartcontract/cli/src/link/accept.rs | 2 + smartcontract/cli/src/link/delete.rs | 2 + smartcontract/cli/src/link/dzx_create.rs | 2 + smartcontract/cli/src/link/get.rs | 2 + smartcontract/cli/src/link/latency.rs | 1 + smartcontract/cli/src/link/list.rs | 18 + smartcontract/cli/src/link/sethealth.rs | 4 + smartcontract/cli/src/link/update.rs | 4 + smartcontract/cli/src/link/wan_create.rs | 2 + .../src/instructions.rs | 1 + .../src/processors/link/create.rs | 1 + .../src/processors/link/update.rs | 15 + .../src/processors/topology/clear.rs | 81 +- .../src/processors/topology/delete.rs | 78 +- .../src/state/link.rs | 18 +- .../tests/link_wan_test.rs | 1 + .../tests/topology_test.rs | 881 +++++++++++++++++- ...initialize_device_latency_samples_tests.rs | 1 + .../sdk/rs/src/commands/link/accept.rs | 4 + .../sdk/rs/src/commands/link/activate.rs | 4 + .../sdk/rs/src/commands/link/closeaccount.rs | 4 + .../sdk/rs/src/commands/link/delete.rs | 2 + .../sdk/rs/src/commands/link/update.rs | 1 + 26 files changed, 1127 insertions(+), 17 deletions(-) diff --git a/activator/src/process/link.rs b/activator/src/process/link.rs index fee82e46be..91344d130f 100644 --- a/activator/src/process/link.rs +++ b/activator/src/process/link.rs @@ -271,6 +271,8 @@ mod tests { link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let tunnel_cloned = tunnel.clone(); @@ -397,6 +399,8 @@ mod tests { side_z_iface_name: "Ethernet1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let link_cloned = link.clone(); @@ -457,6 +461,8 @@ mod tests { link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let tunnel_clone = tunnel.clone(); @@ -544,6 +550,8 @@ mod tests { link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; // SDK command fetches the link internally @@ -623,6 +631,8 @@ mod tests { link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; // SDK command fetches the link internally diff --git a/activator/src/processor.rs b/activator/src/processor.rs index 21d115a30d..2ddd3c72d8 100644 --- a/activator/src/processor.rs +++ b/activator/src/processor.rs @@ -761,6 +761,8 @@ mod tests { side_z_iface_name: "Ethernet1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let mut existing_links: HashMap = HashMap::new(); @@ -795,6 +797,8 @@ mod tests { side_z_iface_name: "Ethernet3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let new_link_cloned = new_link.clone(); diff --git a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs index 254c9f9ca0..a688c58184 100644 --- a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs +++ b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs @@ -433,6 +433,7 @@ fn generate_link(dir: &Path) { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; let data = borsh::to_vec(&val).unwrap(); diff --git a/smartcontract/cli/src/link/accept.rs b/smartcontract/cli/src/link/accept.rs index 7f438bca40..47d301689b 100644 --- a/smartcontract/cli/src/link/accept.rs +++ b/smartcontract/cli/src/link/accept.rs @@ -251,6 +251,8 @@ mod tests { side_z_iface_name: "Ethernet1/2".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; client diff --git a/smartcontract/cli/src/link/delete.rs b/smartcontract/cli/src/link/delete.rs index e2be7325f8..0a6e460a99 100644 --- a/smartcontract/cli/src/link/delete.rs +++ b/smartcontract/cli/src/link/delete.rs @@ -151,6 +151,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; client diff --git a/smartcontract/cli/src/link/dzx_create.rs b/smartcontract/cli/src/link/dzx_create.rs index 3d8e83e918..bbbddf782b 100644 --- a/smartcontract/cli/src/link/dzx_create.rs +++ b/smartcontract/cli/src/link/dzx_create.rs @@ -359,6 +359,8 @@ mod tests { side_z_iface_name: "Ethernet1/2".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; client diff --git a/smartcontract/cli/src/link/get.rs b/smartcontract/cli/src/link/get.rs index 784d51c0c3..5ec3e9bd67 100644 --- a/smartcontract/cli/src/link/get.rs +++ b/smartcontract/cli/src/link/get.rs @@ -158,6 +158,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let contributor = Contributor { diff --git a/smartcontract/cli/src/link/latency.rs b/smartcontract/cli/src/link/latency.rs index a62f7f0bfe..7ac13a2164 100644 --- a/smartcontract/cli/src/link/latency.rs +++ b/smartcontract/cli/src/link/latency.rs @@ -190,6 +190,7 @@ mod tests { delay_override_ns: 0, link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: Vec::new(), } } diff --git a/smartcontract/cli/src/link/list.rs b/smartcontract/cli/src/link/list.rs index f4f85be978..fa9ab2841f 100644 --- a/smartcontract/cli/src/link/list.rs +++ b/smartcontract/cli/src/link/list.rs @@ -377,6 +377,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; client.expect_list_link().returning(move |_| { @@ -571,6 +573,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let tunnel2_pubkey = Pubkey::new_unique(); let tunnel2 = Link { @@ -595,6 +599,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; client.expect_list_link().returning(move |_| { @@ -743,6 +749,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -768,6 +776,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; client.expect_list_link().returning(move |_| { @@ -916,6 +926,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -941,6 +953,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; client.expect_list_link().returning(move |_| { @@ -1056,6 +1070,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -1081,6 +1097,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; client.expect_list_link().returning(move |_| { diff --git a/smartcontract/cli/src/link/sethealth.rs b/smartcontract/cli/src/link/sethealth.rs index 9e1409d9b7..dfd28af727 100644 --- a/smartcontract/cli/src/link/sethealth.rs +++ b/smartcontract/cli/src/link/sethealth.rs @@ -105,6 +105,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let link2 = Link { @@ -129,6 +131,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; client diff --git a/smartcontract/cli/src/link/update.rs b/smartcontract/cli/src/link/update.rs index 09980d60c4..714f7ed87c 100644 --- a/smartcontract/cli/src/link/update.rs +++ b/smartcontract/cli/src/link/update.rs @@ -206,6 +206,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let link2 = Link { @@ -230,6 +232,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; client diff --git a/smartcontract/cli/src/link/wan_create.rs b/smartcontract/cli/src/link/wan_create.rs index 9c6a4b8872..f179f104f1 100644 --- a/smartcontract/cli/src/link/wan_create.rs +++ b/smartcontract/cli/src/link/wan_create.rs @@ -406,6 +406,8 @@ mod tests { side_z_iface_name: "Ethernet1/2".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; client diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index e351d3ed4a..f9a092294b 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -824,6 +824,7 @@ mod tests { tunnel_id: None, tunnel_net: None, use_onchain_allocation: false, + link_topologies: None, }), "UpdateLink", ); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs index b03185c6e8..d5f6f4b15a 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs @@ -224,6 +224,7 @@ pub fn process_create_link( // link_health: LinkHealth::Pending, link_health: LinkHealth::ReadyForService, // Force the link to be ready for service until the health oracle is implemented, desired_status: value.desired_status.unwrap_or(LinkDesiredStatus::Activated), + link_topologies: Vec::new(), }; link.check_status_transition(); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs index 5318ab3c88..5856a4afe2 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs @@ -43,6 +43,7 @@ pub struct LinkUpdateArgs { pub tunnel_net: Option, #[incremental(default = false)] pub use_onchain_allocation: bool, + pub link_topologies: Option>, } impl fmt::Debug for LinkUpdateArgs { @@ -87,6 +88,9 @@ impl fmt::Debug for LinkUpdateArgs { if self.use_onchain_allocation { parts.push("use_onchain_allocation: true".to_string()); } + if let Some(ref link_topologies) = self.link_topologies { + parts.push(format!("link_topologies: {:?}", link_topologies)); + } write!(f, "{}", parts.join(", ")) } } @@ -361,6 +365,15 @@ pub fn process_update_link( try_acc_write(&side_z_dev, device_z_account, payer_account, accounts)?; } + // link_topologies is foundation-only + if let Some(link_topologies) = &value.link_topologies { + if !globalstate.foundation_allowlist.contains(payer_account.key) { + msg!("link_topologies update requires foundation allowlist"); + return Err(DoubleZeroError::NotAllowed.into()); + } + link.link_topologies = link_topologies.clone(); + } + link.check_status_transition(); try_acc_write(&link, link_account, payer_account, accounts)?; @@ -416,6 +429,7 @@ mod tests { tunnel_id: None, tunnel_net: None, use_onchain_allocation: false, + link_topologies: None, }; let serialized = borsh::to_vec(&args_before).unwrap(); @@ -469,6 +483,7 @@ mod tests { tunnel_id: None, tunnel_net: None, use_onchain_allocation: false, + link_topologies: None, }; let serialized = borsh::to_vec(&args_before).unwrap(); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs index 12a533c238..f7c5757f55 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs @@ -1,16 +1,87 @@ +use crate::{ + error::DoubleZeroError, + pda::get_topology_pda, + serializer::try_acc_write, + state::{globalstate::GlobalState, link::Link}, +}; use borsh::BorshSerialize; use borsh_incremental::BorshDeserializeIncremental; -use solana_program::{account_info::AccountInfo, entrypoint::ProgramResult, pubkey::Pubkey}; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + pubkey::Pubkey, +}; #[derive(BorshSerialize, BorshDeserializeIncremental, Debug, Clone, PartialEq)] pub struct TopologyClearArgs { pub name: String, } +/// Accounts layout: +/// [0] topology PDA (readonly, for key validation) +/// [1] globalstate (readonly) +/// [2] payer (writable, signer, must be in foundation_allowlist) +/// [3+] Link accounts (writable) — remove topology pubkey from link_topologies on each pub fn process_topology_clear( - _program_id: &Pubkey, - _accounts: &[AccountInfo], - _value: &TopologyClearArgs, + program_id: &Pubkey, + accounts: &[AccountInfo], + value: &TopologyClearArgs, ) -> ProgramResult { - todo!("TopologyClear not yet implemented") + let accounts_iter = &mut accounts.iter(); + + let topology_account = next_account_info(accounts_iter)?; + let globalstate_account = next_account_info(accounts_iter)?; + let payer_account = next_account_info(accounts_iter)?; + + #[cfg(test)] + msg!("process_topology_clear(name={})", value.name); + + // Payer must be a signer + assert!(payer_account.is_signer, "Payer must be a signer"); + + // Authorization: foundation keys only + let globalstate = GlobalState::try_from(globalstate_account)?; + if !globalstate.foundation_allowlist.contains(payer_account.key) { + msg!("TopologyClear: unauthorized — foundation key required"); + return Err(DoubleZeroError::Unauthorized.into()); + } + + // Validate topology PDA + let (expected_pda, _) = get_topology_pda(program_id, &value.name); + assert_eq!( + topology_account.key, &expected_pda, + "TopologyClear: invalid topology PDA for name '{}'", + value.name + ); + + // We don't require the topology to still exist (it may already be closed). + // The validation above confirms the key matches the expected PDA for the name. + + let topology_key = topology_account.key; + let mut cleared_count: usize = 0; + + // Process remaining Link accounts: remove topology key from link_topologies + for link_account in accounts_iter { + if link_account.data_is_empty() { + continue; + } + let mut link = match Link::try_from(link_account) { + Ok(l) => l, + Err(_) => continue, + }; + let before_len = link.link_topologies.len(); + link.link_topologies.retain(|k| k != topology_key); + if link.link_topologies.len() < before_len { + try_acc_write(&link, link_account, payer_account, accounts)?; + cleared_count += 1; + } + } + + msg!( + "TopologyClear: removed topology '{}' from {} link(s)", + value.name, + cleared_count + ); + Ok(()) } diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs index e9e30cb711..d42a37c534 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs @@ -1,16 +1,84 @@ +use crate::{ + error::DoubleZeroError, + pda::get_topology_pda, + serializer::try_acc_close, + state::{globalstate::GlobalState, link::Link, topology::TopologyInfo}, +}; use borsh::BorshSerialize; use borsh_incremental::BorshDeserializeIncremental; -use solana_program::{account_info::AccountInfo, entrypoint::ProgramResult, pubkey::Pubkey}; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + pubkey::Pubkey, +}; #[derive(BorshSerialize, BorshDeserializeIncremental, Debug, Clone, PartialEq)] pub struct TopologyDeleteArgs { pub name: String, } +/// Accounts layout: +/// [0] topology PDA (writable, to be closed) +/// [1] globalstate (readonly) +/// [2] payer (writable, signer, must be in foundation_allowlist) +/// [3+] Link accounts (readonly) — guard: fail if any references this topology pub fn process_topology_delete( - _program_id: &Pubkey, - _accounts: &[AccountInfo], - _value: &TopologyDeleteArgs, + program_id: &Pubkey, + accounts: &[AccountInfo], + value: &TopologyDeleteArgs, ) -> ProgramResult { - todo!("TopologyDelete not yet implemented") + let accounts_iter = &mut accounts.iter(); + + let topology_account = next_account_info(accounts_iter)?; + let globalstate_account = next_account_info(accounts_iter)?; + let payer_account = next_account_info(accounts_iter)?; + + #[cfg(test)] + msg!("process_topology_delete(name={})", value.name); + + // Payer must be a signer + assert!(payer_account.is_signer, "Payer must be a signer"); + + // Authorization: foundation keys only + let globalstate = GlobalState::try_from(globalstate_account)?; + if !globalstate.foundation_allowlist.contains(payer_account.key) { + msg!("TopologyDelete: unauthorized — foundation key required"); + return Err(DoubleZeroError::Unauthorized.into()); + } + + // Validate topology PDA + let (expected_pda, _) = get_topology_pda(program_id, &value.name); + assert_eq!( + topology_account.key, &expected_pda, + "TopologyDelete: invalid topology PDA for name '{}'", + value.name + ); + + // Deserialize topology to get its pubkey for reference checks + let _topology = TopologyInfo::try_from(topology_account)?; + + // Check remaining Link accounts — fail if any reference this topology + for link_account in accounts_iter { + if link_account.data_is_empty() { + continue; + } + if let Ok(link) = Link::try_from(link_account) { + if link.link_topologies.contains(topology_account.key) { + msg!( + "TopologyDelete: link {} still references topology {}", + link_account.key, + topology_account.key + ); + return Err(DoubleZeroError::ReferenceCountNotZero.into()); + } + } + } + + // Close the topology PDA (transfer lamports to payer, zero data) + // NOTE: We do NOT deallocate the admin-group bit — bits are permanently marked. + try_acc_close(topology_account, payer_account)?; + + msg!("TopologyDelete: closed topology '{}'", value.name); + Ok(()) } diff --git a/smartcontract/programs/doublezero-serviceability/src/state/link.rs b/smartcontract/programs/doublezero-serviceability/src/state/link.rs index 2a2025ddea..0bbaaa437b 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/link.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/link.rs @@ -264,14 +264,15 @@ pub struct Link { pub delay_override_ns: u64, // 8 pub link_health: LinkHealth, // 1 pub desired_status: LinkDesiredStatus, // 1 + pub link_topologies: Vec, // 4 + 32 * len } impl fmt::Display for Link { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "account_type: {}, owner: {}, index: {}, side_a_pk: {}, side_z_pk: {}, tunnel_type: {}, bandwidth: {}, mtu: {}, delay_ns: {}, jitter_ns: {}, tunnel_id: {}, tunnel_net: {}, status: {}, code: {}, contributor_pk: {}, link_health: {}, desired_status: {}", - self.account_type, self.owner, self.index, self.side_a_pk, self.side_z_pk, self.link_type, self.bandwidth, self.mtu, self.delay_ns, self.jitter_ns, self.tunnel_id, &self.tunnel_net, self.status, self.code, self.contributor_pk, self.link_health, self.desired_status + "account_type: {}, owner: {}, index: {}, side_a_pk: {}, side_z_pk: {}, tunnel_type: {}, bandwidth: {}, mtu: {}, delay_ns: {}, jitter_ns: {}, tunnel_id: {}, tunnel_net: {}, status: {}, code: {}, contributor_pk: {}, link_health: {}, desired_status: {}, link_topologies: {:?}", + self.account_type, self.owner, self.index, self.side_a_pk, self.side_z_pk, self.link_type, self.bandwidth, self.mtu, self.delay_ns, self.jitter_ns, self.tunnel_id, &self.tunnel_net, self.status, self.code, self.contributor_pk, self.link_health, self.desired_status, self.link_topologies ) } } @@ -300,6 +301,7 @@ impl Default for Link { delay_override_ns: 0, link_health: LinkHealth::Pending, desired_status: LinkDesiredStatus::Pending, + link_topologies: Vec::new(), } } } @@ -330,6 +332,7 @@ impl TryFrom<&[u8]> for Link { delay_override_ns: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), link_health: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), desired_status: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + link_topologies: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), }; if out.account_type != AccountType::Link { @@ -549,6 +552,7 @@ mod tests { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; let data = borsh::to_vec(&val).unwrap(); @@ -602,6 +606,7 @@ mod tests { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; let err = val.validate(); assert!(err.is_err()); @@ -632,6 +637,7 @@ mod tests { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; let err = val.validate(); assert!(err.is_err()); @@ -662,6 +668,7 @@ mod tests { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; // For Rejected status, tunnel_net is not validated and should succeed @@ -692,6 +699,7 @@ mod tests { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; let err = val.validate(); assert!(err.is_err()); @@ -722,6 +730,7 @@ mod tests { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -760,6 +769,7 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -799,6 +809,7 @@ mod tests { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; let err = val.validate(); @@ -830,6 +841,7 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -868,6 +880,7 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -906,6 +919,7 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; assert!(bad_link.validate().is_ok()); } diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index 030491e106..7538180d3d 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -606,6 +606,7 @@ async fn test_wan_link() { tunnel_id: None, tunnel_net: None, use_onchain_allocation: false, + link_topologies: None, }), vec![ AccountMeta::new(tunnel_pubkey, false), diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index 42499190fe..fd612c24f4 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -3,25 +3,33 @@ use doublezero_serviceability::{ instructions::DoubleZeroInstruction, pda::{ - get_contributor_pda, get_device_pda, get_exchange_pda, get_globalconfig_pda, + get_contributor_pda, get_device_pda, get_exchange_pda, get_globalconfig_pda, get_link_pda, get_location_pda, get_resource_extension_pda, get_topology_pda, }, processors::{ contributor::create::ContributorCreateArgs, device::{ - activate::DeviceActivateArgs, create::DeviceCreateArgs, - interface::create::DeviceInterfaceCreateArgs, + activate::DeviceActivateArgs, + create::DeviceCreateArgs, + interface::{ + activate::DeviceInterfaceActivateArgs, create::DeviceInterfaceCreateArgs, + unlink::DeviceInterfaceUnlinkArgs, + }, }, exchange::create::ExchangeCreateArgs, + link::{activate::LinkActivateArgs, create::LinkCreateArgs, update::LinkUpdateArgs}, location::create::LocationCreateArgs, resource::create::ResourceCreateArgs, - topology::create::TopologyCreateArgs, + topology::{ + clear::TopologyClearArgs, create::TopologyCreateArgs, delete::TopologyDeleteArgs, + }, }, resource::{IdOrIp, ResourceType}, state::{ accounttype::AccountType, device::{DeviceDesiredStatus, DeviceType}, interface::{InterfaceCYOA, InterfaceDIA, LoopbackType, RoutingMode}, + link::{Link, LinkDesiredStatus, LinkLinkType}, topology::{TopologyConstraint, TopologyInfo}, }, }; @@ -711,7 +719,7 @@ async fn test_topology_create_backfills_vpnv4_loopbacks() { // Step 9: Idempotency — call CreateTopology for unicast-secondary again with the same device. // The segment for unicast-secondary must not be duplicated. - let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + let _recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; // We need a new topology PDA since unicast-secondary already exists; // instead use unicast-secondary's PDA but re-create a third topology and pass the device twice. // Actually the simplest idempotency check: use a third unique topology but re-pass the device — @@ -744,3 +752,866 @@ async fn test_topology_create_backfills_vpnv4_loopbacks() { println!("[PASS] test_topology_create_backfills_vpnv4_loopbacks"); } + +// ============================================================================ +// Helpers for delete/clear tests +// ============================================================================ + +/// Creates a delete topology instruction. +async fn delete_topology( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + name: &str, + extra_link_accounts: Vec, + payer: &Keypair, +) -> Result<(), BanksClientError> { + let (topology_pda, _) = get_topology_pda(&program_id, name); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let base_accounts = vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]; + let extra_accounts: Vec = extra_link_accounts; + let mut tx = create_transaction_with_extra_accounts( + program_id, + &DoubleZeroInstruction::DeleteTopology(TopologyDeleteArgs { + name: name.to_string(), + }), + &base_accounts, + payer, + &extra_accounts, + ); + tx.try_sign(&[&payer], recent_blockhash).unwrap(); + banks_client.process_transaction(tx).await +} + +/// Creates a clear topology instruction, passing the given link accounts as writable. +async fn clear_topology( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + name: &str, + link_accounts: Vec, + payer: &Keypair, +) { + let (topology_pda, _) = get_topology_pda(&program_id, name); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let base_accounts = vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]; + let mut tx = create_transaction_with_extra_accounts( + program_id, + &DoubleZeroInstruction::ClearTopology(TopologyClearArgs { + name: name.to_string(), + }), + &base_accounts, + payer, + &link_accounts, + ); + tx.try_sign(&[&payer], recent_blockhash).unwrap(); + banks_client.process_transaction(tx).await.unwrap(); +} + +/// Gets a Link account (panics if not found or not deserializable). +async fn get_link(banks_client: &mut BanksClient, pubkey: Pubkey) -> Link { + let account = banks_client + .get_account(pubkey) + .await + .unwrap() + .expect("Link account should exist"); + Link::try_from(&account.data[..]).expect("Should deserialize as Link") +} + +/// Sets up a minimal WAN link (two devices, contributor, location, exchange, one link). +/// Returns (link_pubkey, contributor_pubkey, device_a_pubkey, device_z_pubkey). +#[allow(clippy::too_many_arguments)] +async fn setup_wan_link( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + payer: &Keypair, +) -> (Pubkey, Pubkey, Pubkey, Pubkey) { + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + // Location + let globalstate_account = get_globalstate(banks_client, globalstate_pubkey).await; + let (location_pubkey, _) = get_location_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateLocation(LocationCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + country: "us".to_string(), + lat: 1.234, + lng: 4.567, + loc_id: 0, + }), + vec![ + AccountMeta::new(location_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Exchange + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + let globalstate_account = get_globalstate(banks_client, globalstate_pubkey).await; + let (exchange_pubkey, _) = get_exchange_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateExchange(ExchangeCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + lat: 1.234, + lng: 4.567, + reserved: 0, + }), + vec![ + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Contributor + let globalstate_account = get_globalstate(banks_client, globalstate_pubkey).await; + let (contributor_pubkey, _) = + get_contributor_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateContributor(ContributorCreateArgs { + code: "cont".to_string(), + }), + vec![ + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(payer.pubkey(), false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Device A + let globalstate_account = get_globalstate(banks_client, globalstate_pubkey).await; + let (device_a_pubkey, _) = get_device_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDevice(DeviceCreateArgs { + code: "dza".to_string(), + device_type: DeviceType::Hybrid, + public_ip: [8, 8, 8, 8].into(), + dz_prefixes: "110.1.0.0/23".parse().unwrap(), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "mgmt".to_string(), + desired_status: Some(DeviceDesiredStatus::Activated), + resource_count: 0, + }), + vec![ + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Device A interface + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDeviceInterface(DeviceInterfaceCreateArgs { + name: "Ethernet0".to_string(), + interface_dia: InterfaceDIA::None, + loopback_type: LoopbackType::None, + interface_cyoa: InterfaceCYOA::None, + bandwidth: 0, + ip_net: None, + cir: 0, + mtu: 1500, + routing_mode: RoutingMode::Static, + vlan_id: 0, + user_tunnel_endpoint: false, + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateDeviceInterface(DeviceInterfaceActivateArgs { + name: "Ethernet0".to_string(), + ip_net: "10.0.0.0/31".parse().unwrap(), + node_segment_idx: 0, + }), + vec![ + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Device Z + let globalstate_account = get_globalstate(banks_client, globalstate_pubkey).await; + let (device_z_pubkey, _) = get_device_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDevice(DeviceCreateArgs { + code: "dzb".to_string(), + device_type: DeviceType::Hybrid, + public_ip: [9, 9, 9, 9].into(), + dz_prefixes: "111.1.0.0/23".parse().unwrap(), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "mgmt".to_string(), + desired_status: Some(DeviceDesiredStatus::Activated), + resource_count: 0, + }), + vec![ + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Device Z interface + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDeviceInterface(DeviceInterfaceCreateArgs { + name: "Ethernet1".to_string(), + interface_dia: InterfaceDIA::None, + loopback_type: LoopbackType::None, + interface_cyoa: InterfaceCYOA::None, + bandwidth: 0, + ip_net: None, + cir: 0, + mtu: 1500, + routing_mode: RoutingMode::Static, + vlan_id: 0, + user_tunnel_endpoint: false, + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateDeviceInterface(DeviceInterfaceActivateArgs { + name: "Ethernet1".to_string(), + ip_net: "10.0.0.1/31".parse().unwrap(), + node_segment_idx: 0, + }), + vec![ + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Unlink interfaces (make them available for linking) + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UnlinkDeviceInterface(DeviceInterfaceUnlinkArgs { + name: "Ethernet0".to_string(), + }), + vec![ + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UnlinkDeviceInterface(DeviceInterfaceUnlinkArgs { + name: "Ethernet1".to_string(), + }), + vec![ + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Create link + let globalstate_account = get_globalstate(banks_client, globalstate_pubkey).await; + let (link_pubkey, _) = get_link_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateLink(LinkCreateArgs { + code: "dza-dzb".to_string(), + link_type: LinkLinkType::WAN, + bandwidth: 20_000_000_000, + mtu: 9000, + delay_ns: 1_000_000, + jitter_ns: 100_000, + side_a_iface_name: "Ethernet0".to_string(), + side_z_iface_name: Some("Ethernet1".to_string()), + desired_status: Some(LinkDesiredStatus::Activated), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Activate link + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.100.0.0/30".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + ( + link_pubkey, + contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + ) +} + +/// Assigns link_topologies on a link via LinkUpdate (foundation-only). +async fn assign_link_topology( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + link_pubkey: Pubkey, + contributor_pubkey: Pubkey, + topology_pubkeys: Vec, + payer: &Keypair, +) { + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + link_topologies: Some(topology_pubkeys), + ..Default::default() + }), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + payer, + ) + .await; +} + +// ============================================================================ +// TopologyDelete tests +// ============================================================================ + +#[tokio::test] +async fn test_topology_delete_succeeds_when_no_links() { + println!("[TEST] test_topology_delete_succeeds_when_no_links"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "test-topology", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Verify it exists + let topology = get_topology(&mut banks_client, topology_pda).await; + assert_eq!(topology.name, "test-topology"); + + // Delete it with no link accounts + delete_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "test-topology", + vec![], + &payer, + ) + .await + .expect("Delete should succeed with no referencing links"); + + // Verify account data is zeroed (closed) + let account = banks_client.get_account(topology_pda).await.unwrap(); + assert!( + account.is_none() || account.unwrap().data.is_empty(), + "Topology account should be closed after delete" + ); + + println!("[PASS] test_topology_delete_succeeds_when_no_links"); +} + +#[tokio::test] +async fn test_topology_delete_fails_when_link_references_it() { + println!("[TEST] test_topology_delete_fails_when_link_references_it"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "test-topology", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Set up a WAN link and assign the topology to it + let (link_pubkey, contributor_pubkey, _, _) = + setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + assign_link_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + link_pubkey, + contributor_pubkey, + vec![topology_pda], + &payer, + ) + .await; + + // Verify the link references the topology + let link = get_link(&mut banks_client, link_pubkey).await; + assert!(link.link_topologies.contains(&topology_pda)); + + // Attempt to delete — should fail because the link still references it + let result = delete_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "test-topology", + vec![AccountMeta::new_readonly(link_pubkey, false)], + &payer, + ) + .await; + + // DoubleZeroError::ReferenceCountNotZero = Custom(13) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(13), + ))) => {} + _ => panic!( + "Expected ReferenceCountNotZero error (Custom(13)), got {:?}", + result + ), + } + + println!("[PASS] test_topology_delete_fails_when_link_references_it"); +} + +#[tokio::test] +async fn test_topology_delete_bit_not_reused() { + println!("[TEST] test_topology_delete_bit_not_reused"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + // Create "topology-a" — gets bit 0 + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "topology-a", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Delete "topology-a" + delete_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topology-a", + vec![], + &payer, + ) + .await + .expect("Delete should succeed"); + + // Create "topology-b" — must NOT get bit 0 (permanently marked) or bit 1 (UNICAST-DRAINED) + // so it should get bit 2 + let topology_b_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "topology-b", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + let topology_b = get_topology(&mut banks_client, topology_b_pda).await; + assert_eq!( + topology_b.admin_group_bit, 2, + "topology-b should get bit 2 (bit 0 permanently marked even after delete, bit 1 is UNICAST-DRAINED)" + ); + + println!("[PASS] test_topology_delete_bit_not_reused"); +} + +// ============================================================================ +// TopologyClear tests +// ============================================================================ + +#[tokio::test] +async fn test_topology_clear_removes_from_links() { + println!("[TEST] test_topology_clear_removes_from_links"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "test-topology", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Set up a WAN link and assign the topology to it + let (link_pubkey, contributor_pubkey, _, _) = + setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + assign_link_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + link_pubkey, + contributor_pubkey, + vec![topology_pda], + &payer, + ) + .await; + + // Verify assignment + let link = get_link(&mut banks_client, link_pubkey).await; + assert!(link.link_topologies.contains(&topology_pda)); + + // Clear topology from the link + clear_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "test-topology", + vec![AccountMeta::new(link_pubkey, false)], + &payer, + ) + .await; + + // Verify the link no longer references the topology + let link = get_link(&mut banks_client, link_pubkey).await; + assert!( + !link.link_topologies.contains(&topology_pda), + "link_topologies should be empty after clear" + ); + assert!(link.link_topologies.is_empty()); + + println!("[PASS] test_topology_clear_removes_from_links"); +} + +#[tokio::test] +async fn test_topology_clear_is_idempotent() { + println!("[TEST] test_topology_clear_is_idempotent"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "test-topology", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Set up a WAN link but do NOT assign the topology + let (link_pubkey, _, _, _) = + setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + // Verify link has no topology assignment + let link = get_link(&mut banks_client, link_pubkey).await; + assert!(link.link_topologies.is_empty()); + + // Call clear — link does not reference topology, so nothing should change, no error + clear_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "test-topology", + vec![AccountMeta::new(link_pubkey, false)], + &payer, + ) + .await; + + // Verify link is still empty + let link = get_link(&mut banks_client, link_pubkey).await; + assert!( + link.link_topologies.is_empty(), + "link_topologies should still be empty" + ); + + println!("[PASS] test_topology_clear_is_idempotent"); +} + +#[tokio::test] +async fn test_topology_delete_non_foundation_rejected() { + println!("[TEST] test_topology_delete_non_foundation_rejected"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + // Create topology with foundation payer + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Use a keypair that is NOT in the foundation allowlist + let non_foundation = Keypair::new(); + // Fund the non-foundation keypair so it can sign transactions + transfer( + &mut banks_client, + &payer, + &non_foundation.pubkey(), + 10_000_000, + ) + .await; + + let result = delete_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "unicast-default", + vec![], + &non_foundation, + ) + .await; + + // DoubleZeroError::Unauthorized = Custom(22) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(22), + ))) => {} + _ => panic!("Expected Unauthorized error (Custom(22)), got {:?}", result), + } + + println!("[PASS] test_topology_delete_non_foundation_rejected"); +} + +#[tokio::test] +async fn test_topology_clear_non_foundation_rejected() { + println!("[TEST] test_topology_clear_non_foundation_rejected"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + // Create topology with foundation payer + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Use a keypair that is NOT in the foundation allowlist + let non_foundation = Keypair::new(); + // Fund the non-foundation keypair so it can sign transactions + transfer( + &mut banks_client, + &payer, + &non_foundation.pubkey(), + 10_000_000, + ) + .await; + + // Attempt ClearTopology with non-foundation payer + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ClearTopology(TopologyClearArgs { + name: "unicast-default".to_string(), + }), + vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &non_foundation, + ) + .await; + + // DoubleZeroError::Unauthorized = Custom(22) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(22), + ))) => {} + _ => panic!("Expected Unauthorized error (Custom(22)), got {:?}", result), + } + + println!("[PASS] test_topology_clear_non_foundation_rejected"); +} diff --git a/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs b/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs index 1c5b3afc44..46cd871d13 100644 --- a/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs +++ b/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs @@ -710,6 +710,7 @@ async fn test_initialize_device_latency_samples_fail_link_wrong_owner() { side_z_iface_name: "Ethernet1".to_string(), link_health: LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; let mut data = Vec::new(); diff --git a/smartcontract/sdk/rs/src/commands/link/accept.rs b/smartcontract/sdk/rs/src/commands/link/accept.rs index ec736b3aa7..7486d3bf67 100644 --- a/smartcontract/sdk/rs/src/commands/link/accept.rs +++ b/smartcontract/sdk/rs/src/commands/link/accept.rs @@ -134,6 +134,8 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Requested, desired_status: LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let device_z = doublezero_serviceability::state::device::Device { @@ -235,6 +237,8 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Requested, desired_status: LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let device_z = doublezero_serviceability::state::device::Device { diff --git a/smartcontract/sdk/rs/src/commands/link/activate.rs b/smartcontract/sdk/rs/src/commands/link/activate.rs index b3e439bb27..93d370c8ef 100644 --- a/smartcontract/sdk/rs/src/commands/link/activate.rs +++ b/smartcontract/sdk/rs/src/commands/link/activate.rs @@ -126,6 +126,8 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Pending, desired_status: LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; // Mock Link fetch @@ -195,6 +197,8 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Pending, desired_status: LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; // Compute ResourceExtension PDAs diff --git a/smartcontract/sdk/rs/src/commands/link/closeaccount.rs b/smartcontract/sdk/rs/src/commands/link/closeaccount.rs index bd5cd13b74..df86654759 100644 --- a/smartcontract/sdk/rs/src/commands/link/closeaccount.rs +++ b/smartcontract/sdk/rs/src/commands/link/closeaccount.rs @@ -115,6 +115,8 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Deleting, desired_status: LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; // Mock Link fetch @@ -185,6 +187,8 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Deleting, desired_status: LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; // Compute ResourceExtension PDAs diff --git a/smartcontract/sdk/rs/src/commands/link/delete.rs b/smartcontract/sdk/rs/src/commands/link/delete.rs index 181fdd478a..e5bffb05fa 100644 --- a/smartcontract/sdk/rs/src/commands/link/delete.rs +++ b/smartcontract/sdk/rs/src/commands/link/delete.rs @@ -106,6 +106,8 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Activated, desired_status: LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), } } diff --git a/smartcontract/sdk/rs/src/commands/link/update.rs b/smartcontract/sdk/rs/src/commands/link/update.rs index 3df2feff7b..41c8009196 100644 --- a/smartcontract/sdk/rs/src/commands/link/update.rs +++ b/smartcontract/sdk/rs/src/commands/link/update.rs @@ -100,6 +100,7 @@ impl UpdateLinkCommand { tunnel_id: self.tunnel_id, tunnel_net: self.tunnel_net, use_onchain_allocation, + link_topologies: None, }), accounts, ) From 88b151e26c74ee3e98e51b53702d6d81d3a8219f Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 31 Mar 2026 22:59:13 -0500 Subject: [PATCH 11/49] smartcontract: add unicast_drained field to Link; contributor-writable --- activator/src/process/link.rs | 5 + activator/src/processor.rs | 2 + .../fixtures/generate-fixtures/src/main.rs | 1 + smartcontract/cli/src/link/accept.rs | 1 + smartcontract/cli/src/link/delete.rs | 1 + smartcontract/cli/src/link/dzx_create.rs | 1 + smartcontract/cli/src/link/get.rs | 1 + smartcontract/cli/src/link/latency.rs | 1 + smartcontract/cli/src/link/list.rs | 9 + smartcontract/cli/src/link/sethealth.rs | 2 + smartcontract/cli/src/link/update.rs | 2 + smartcontract/cli/src/link/wan_create.rs | 1 + .../src/instructions.rs | 1 + .../src/processors/link/create.rs | 1 + .../src/processors/link/update.rs | 18 ++ .../src/state/link.rs | 18 +- .../tests/link_wan_test.rs | 1 + .../tests/topology_test.rs | 225 ++++++++++++++++++ ...initialize_device_latency_samples_tests.rs | 1 + .../sdk/rs/src/commands/link/accept.rs | 2 + .../sdk/rs/src/commands/link/activate.rs | 2 + .../sdk/rs/src/commands/link/closeaccount.rs | 2 + .../sdk/rs/src/commands/link/delete.rs | 1 + .../sdk/rs/src/commands/link/update.rs | 1 + 24 files changed, 298 insertions(+), 2 deletions(-) diff --git a/activator/src/process/link.rs b/activator/src/process/link.rs index 91344d130f..347fe13e2e 100644 --- a/activator/src/process/link.rs +++ b/activator/src/process/link.rs @@ -273,6 +273,7 @@ mod tests { doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let tunnel_cloned = tunnel.clone(); @@ -401,6 +402,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let link_cloned = link.clone(); @@ -463,6 +465,7 @@ mod tests { doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let tunnel_clone = tunnel.clone(); @@ -552,6 +555,7 @@ mod tests { doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; // SDK command fetches the link internally @@ -633,6 +637,7 @@ mod tests { doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; // SDK command fetches the link internally diff --git a/activator/src/processor.rs b/activator/src/processor.rs index 2ddd3c72d8..86caa72455 100644 --- a/activator/src/processor.rs +++ b/activator/src/processor.rs @@ -763,6 +763,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let mut existing_links: HashMap = HashMap::new(); @@ -799,6 +800,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let new_link_cloned = new_link.clone(); diff --git a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs index a688c58184..2ab9f9ae70 100644 --- a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs +++ b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs @@ -434,6 +434,7 @@ fn generate_link(dir: &Path) { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let data = borsh::to_vec(&val).unwrap(); diff --git a/smartcontract/cli/src/link/accept.rs b/smartcontract/cli/src/link/accept.rs index 47d301689b..12596d02f2 100644 --- a/smartcontract/cli/src/link/accept.rs +++ b/smartcontract/cli/src/link/accept.rs @@ -253,6 +253,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; client diff --git a/smartcontract/cli/src/link/delete.rs b/smartcontract/cli/src/link/delete.rs index 0a6e460a99..ebed5115a7 100644 --- a/smartcontract/cli/src/link/delete.rs +++ b/smartcontract/cli/src/link/delete.rs @@ -153,6 +153,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; client diff --git a/smartcontract/cli/src/link/dzx_create.rs b/smartcontract/cli/src/link/dzx_create.rs index bbbddf782b..65cc135006 100644 --- a/smartcontract/cli/src/link/dzx_create.rs +++ b/smartcontract/cli/src/link/dzx_create.rs @@ -361,6 +361,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; client diff --git a/smartcontract/cli/src/link/get.rs b/smartcontract/cli/src/link/get.rs index 5ec3e9bd67..14bc462363 100644 --- a/smartcontract/cli/src/link/get.rs +++ b/smartcontract/cli/src/link/get.rs @@ -160,6 +160,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let contributor = Contributor { diff --git a/smartcontract/cli/src/link/latency.rs b/smartcontract/cli/src/link/latency.rs index 7ac13a2164..0009ebd846 100644 --- a/smartcontract/cli/src/link/latency.rs +++ b/smartcontract/cli/src/link/latency.rs @@ -191,6 +191,7 @@ mod tests { link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, } } diff --git a/smartcontract/cli/src/link/list.rs b/smartcontract/cli/src/link/list.rs index fa9ab2841f..83d015d40f 100644 --- a/smartcontract/cli/src/link/list.rs +++ b/smartcontract/cli/src/link/list.rs @@ -379,6 +379,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; client.expect_list_link().returning(move |_| { @@ -575,6 +576,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let tunnel2_pubkey = Pubkey::new_unique(); let tunnel2 = Link { @@ -601,6 +603,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; client.expect_list_link().returning(move |_| { @@ -751,6 +754,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -778,6 +782,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; client.expect_list_link().returning(move |_| { @@ -928,6 +933,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -955,6 +961,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; client.expect_list_link().returning(move |_| { @@ -1072,6 +1079,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -1099,6 +1107,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; client.expect_list_link().returning(move |_| { diff --git a/smartcontract/cli/src/link/sethealth.rs b/smartcontract/cli/src/link/sethealth.rs index dfd28af727..78a2c02ae8 100644 --- a/smartcontract/cli/src/link/sethealth.rs +++ b/smartcontract/cli/src/link/sethealth.rs @@ -107,6 +107,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let link2 = Link { @@ -133,6 +134,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; client diff --git a/smartcontract/cli/src/link/update.rs b/smartcontract/cli/src/link/update.rs index 714f7ed87c..caefd8b921 100644 --- a/smartcontract/cli/src/link/update.rs +++ b/smartcontract/cli/src/link/update.rs @@ -208,6 +208,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let link2 = Link { @@ -234,6 +235,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; client diff --git a/smartcontract/cli/src/link/wan_create.rs b/smartcontract/cli/src/link/wan_create.rs index f179f104f1..83e538003d 100644 --- a/smartcontract/cli/src/link/wan_create.rs +++ b/smartcontract/cli/src/link/wan_create.rs @@ -408,6 +408,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; client diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index f9a092294b..f34015642d 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -825,6 +825,7 @@ mod tests { tunnel_net: None, use_onchain_allocation: false, link_topologies: None, + unicast_drained: None, }), "UpdateLink", ); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs index d5f6f4b15a..92da7f7bb2 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs @@ -225,6 +225,7 @@ pub fn process_create_link( link_health: LinkHealth::ReadyForService, // Force the link to be ready for service until the health oracle is implemented, desired_status: value.desired_status.unwrap_or(LinkDesiredStatus::Activated), link_topologies: Vec::new(), + unicast_drained: false, }; link.check_status_transition(); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs index 5856a4afe2..fb23677601 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs @@ -44,6 +44,8 @@ pub struct LinkUpdateArgs { #[incremental(default = false)] pub use_onchain_allocation: bool, pub link_topologies: Option>, + #[incremental(default = None)] + pub unicast_drained: Option, } impl fmt::Debug for LinkUpdateArgs { @@ -91,6 +93,9 @@ impl fmt::Debug for LinkUpdateArgs { if let Some(ref link_topologies) = self.link_topologies { parts.push(format!("link_topologies: {:?}", link_topologies)); } + if let Some(unicast_drained) = self.unicast_drained { + parts.push(format!("unicast_drained: {:?}", unicast_drained)); + } write!(f, "{}", parts.join(", ")) } } @@ -374,6 +379,17 @@ pub fn process_update_link( link.link_topologies = link_topologies.clone(); } + // unicast_drained: contributor A or foundation + if let Some(unicast_drained) = value.unicast_drained { + if link.contributor_pk != *contributor_account.key + && !globalstate.foundation_allowlist.contains(payer_account.key) + { + msg!("unicast_drained update requires contributor A or foundation allowlist"); + return Err(DoubleZeroError::NotAllowed.into()); + } + link.unicast_drained = unicast_drained; + } + link.check_status_transition(); try_acc_write(&link, link_account, payer_account, accounts)?; @@ -430,6 +446,7 @@ mod tests { tunnel_net: None, use_onchain_allocation: false, link_topologies: None, + unicast_drained: None, }; let serialized = borsh::to_vec(&args_before).unwrap(); @@ -484,6 +501,7 @@ mod tests { tunnel_net: None, use_onchain_allocation: false, link_topologies: None, + unicast_drained: None, }; let serialized = borsh::to_vec(&args_before).unwrap(); diff --git a/smartcontract/programs/doublezero-serviceability/src/state/link.rs b/smartcontract/programs/doublezero-serviceability/src/state/link.rs index 0bbaaa437b..95d726e48a 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/link.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/link.rs @@ -265,14 +265,15 @@ pub struct Link { pub link_health: LinkHealth, // 1 pub desired_status: LinkDesiredStatus, // 1 pub link_topologies: Vec, // 4 + 32 * len + pub unicast_drained: bool, // 1 } impl fmt::Display for Link { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "account_type: {}, owner: {}, index: {}, side_a_pk: {}, side_z_pk: {}, tunnel_type: {}, bandwidth: {}, mtu: {}, delay_ns: {}, jitter_ns: {}, tunnel_id: {}, tunnel_net: {}, status: {}, code: {}, contributor_pk: {}, link_health: {}, desired_status: {}, link_topologies: {:?}", - self.account_type, self.owner, self.index, self.side_a_pk, self.side_z_pk, self.link_type, self.bandwidth, self.mtu, self.delay_ns, self.jitter_ns, self.tunnel_id, &self.tunnel_net, self.status, self.code, self.contributor_pk, self.link_health, self.desired_status, self.link_topologies + "account_type: {}, owner: {}, index: {}, side_a_pk: {}, side_z_pk: {}, tunnel_type: {}, bandwidth: {}, mtu: {}, delay_ns: {}, jitter_ns: {}, tunnel_id: {}, tunnel_net: {}, status: {}, code: {}, contributor_pk: {}, link_health: {}, desired_status: {}, link_topologies: {:?}, unicast_drained: {}", + self.account_type, self.owner, self.index, self.side_a_pk, self.side_z_pk, self.link_type, self.bandwidth, self.mtu, self.delay_ns, self.jitter_ns, self.tunnel_id, &self.tunnel_net, self.status, self.code, self.contributor_pk, self.link_health, self.desired_status, self.link_topologies, self.unicast_drained ) } } @@ -302,6 +303,7 @@ impl Default for Link { link_health: LinkHealth::Pending, desired_status: LinkDesiredStatus::Pending, link_topologies: Vec::new(), + unicast_drained: false, } } } @@ -333,6 +335,7 @@ impl TryFrom<&[u8]> for Link { link_health: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), desired_status: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), link_topologies: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + unicast_drained: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), }; if out.account_type != AccountType::Link { @@ -553,6 +556,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let data = borsh::to_vec(&val).unwrap(); @@ -607,6 +611,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let err = val.validate(); assert!(err.is_err()); @@ -638,6 +643,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let err = val.validate(); assert!(err.is_err()); @@ -669,6 +675,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; // For Rejected status, tunnel_net is not validated and should succeed @@ -700,6 +707,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let err = val.validate(); assert!(err.is_err()); @@ -731,6 +739,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -770,6 +779,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -810,6 +820,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let err = val.validate(); @@ -842,6 +853,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -881,6 +893,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -920,6 +933,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; assert!(bad_link.validate().is_ok()); } diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index 7538180d3d..bb13dbad51 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -607,6 +607,7 @@ async fn test_wan_link() { tunnel_net: None, use_onchain_allocation: false, link_topologies: None, + unicast_drained: None, }), vec![ AccountMeta::new(tunnel_pubkey, false), diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index fd612c24f4..e62d1380cb 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -1615,3 +1615,228 @@ async fn test_topology_clear_non_foundation_rejected() { println!("[PASS] test_topology_clear_non_foundation_rejected"); } + +// ============================================================================ +// unicast_drained tests +// ============================================================================ + +#[tokio::test] +async fn test_link_unicast_drained_contributor_can_set_own_link() { + println!("[TEST] test_link_unicast_drained_contributor_can_set_own_link"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _) = + setup_program_with_globalconfig().await; + + let (link_pubkey, contributor_pubkey, _, _) = + setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + // Verify unicast_drained starts as false + let link = get_link(&mut banks_client, link_pubkey).await; + assert!(!link.unicast_drained); + + // Contributor A (payer) sets unicast_drained = true + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + unicast_drained: Some(true), + ..Default::default() + }), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Read back: unicast_drained must be true + let link = get_link(&mut banks_client, link_pubkey).await; + assert!(link.unicast_drained); + + println!("[PASS] test_link_unicast_drained_contributor_can_set_own_link"); +} + +#[tokio::test] +async fn test_link_unicast_drained_contributor_cannot_set_other_link() { + println!("[TEST] test_link_unicast_drained_contributor_cannot_set_other_link"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _) = + setup_program_with_globalconfig().await; + + // Create the link owned by payer (contributor A) + let (link_pubkey, _contributor_a_pubkey, _, _) = + setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + // Create a second contributor owned by a different keypair (bad_actor) + let bad_actor = Keypair::new(); + transfer(&mut banks_client, &payer, &bad_actor.pubkey(), 10_000_000).await; + + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (contributor_b_pubkey, _) = + get_contributor_pda(&program_id, globalstate_account.account_index + 1); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + // Foundation (payer) creates contributor B, owned by bad_actor + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateContributor(ContributorCreateArgs { + code: "bad".to_string(), + }), + vec![ + AccountMeta::new(contributor_b_pubkey, false), + AccountMeta::new(bad_actor.pubkey(), false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // bad_actor tries to set unicast_drained on contributor A's link using contributor B + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + unicast_drained: Some(true), + ..Default::default() + }), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(contributor_b_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &bad_actor, + ) + .await; + + // DoubleZeroError::NotAllowed = Custom(8) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(8), + ))) => {} + _ => panic!("Expected NotAllowed error (Custom(8)), got {:?}", result), + } + + println!("[PASS] test_link_unicast_drained_contributor_cannot_set_other_link"); +} + +#[tokio::test] +async fn test_link_unicast_drained_foundation_can_set_any_link() { + println!("[TEST] test_link_unicast_drained_foundation_can_set_any_link"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _) = + setup_program_with_globalconfig().await; + + let (link_pubkey, contributor_pubkey, _, _) = + setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + // payer is in the foundation allowlist; it sets unicast_drained on a contributor's link + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + unicast_drained: Some(true), + ..Default::default() + }), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let link = get_link(&mut banks_client, link_pubkey).await; + assert!(link.unicast_drained); + + println!("[PASS] test_link_unicast_drained_foundation_can_set_any_link"); +} + +#[tokio::test] +async fn test_link_unicast_drained_orthogonal_to_status_and_topologies() { + println!("[TEST] test_link_unicast_drained_orthogonal_to_status_and_topologies"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "test-topology", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + let (link_pubkey, contributor_pubkey, _, _) = + setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + // Assign a topology to the link (foundation-only) + assign_link_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + link_pubkey, + contributor_pubkey, + vec![topology_pda], + &payer, + ) + .await; + + let link_before = get_link(&mut banks_client, link_pubkey).await; + assert!(link_before.link_topologies.contains(&topology_pda)); + assert!(!link_before.unicast_drained); + + // Set unicast_drained = true + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + unicast_drained: Some(true), + ..Default::default() + }), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let link_after = get_link(&mut banks_client, link_pubkey).await; + assert!(link_after.unicast_drained, "unicast_drained should be true"); + assert_eq!( + link_after.status, link_before.status, + "status should be unchanged" + ); + assert_eq!( + link_after.link_topologies, link_before.link_topologies, + "link_topologies should be unchanged" + ); + + println!("[PASS] test_link_unicast_drained_orthogonal_to_status_and_topologies"); +} diff --git a/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs b/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs index 46cd871d13..4c43a407ea 100644 --- a/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs +++ b/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs @@ -711,6 +711,7 @@ async fn test_initialize_device_latency_samples_fail_link_wrong_owner() { link_health: LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let mut data = Vec::new(); diff --git a/smartcontract/sdk/rs/src/commands/link/accept.rs b/smartcontract/sdk/rs/src/commands/link/accept.rs index 7486d3bf67..565be69caf 100644 --- a/smartcontract/sdk/rs/src/commands/link/accept.rs +++ b/smartcontract/sdk/rs/src/commands/link/accept.rs @@ -136,6 +136,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let device_z = doublezero_serviceability::state::device::Device { @@ -239,6 +240,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let device_z = doublezero_serviceability::state::device::Device { diff --git a/smartcontract/sdk/rs/src/commands/link/activate.rs b/smartcontract/sdk/rs/src/commands/link/activate.rs index 93d370c8ef..628a5574ed 100644 --- a/smartcontract/sdk/rs/src/commands/link/activate.rs +++ b/smartcontract/sdk/rs/src/commands/link/activate.rs @@ -128,6 +128,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; // Mock Link fetch @@ -199,6 +200,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; // Compute ResourceExtension PDAs diff --git a/smartcontract/sdk/rs/src/commands/link/closeaccount.rs b/smartcontract/sdk/rs/src/commands/link/closeaccount.rs index df86654759..4a90b74dcc 100644 --- a/smartcontract/sdk/rs/src/commands/link/closeaccount.rs +++ b/smartcontract/sdk/rs/src/commands/link/closeaccount.rs @@ -117,6 +117,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; // Mock Link fetch @@ -189,6 +190,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; // Compute ResourceExtension PDAs diff --git a/smartcontract/sdk/rs/src/commands/link/delete.rs b/smartcontract/sdk/rs/src/commands/link/delete.rs index e5bffb05fa..98c0ef2cbb 100644 --- a/smartcontract/sdk/rs/src/commands/link/delete.rs +++ b/smartcontract/sdk/rs/src/commands/link/delete.rs @@ -108,6 +108,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, } } diff --git a/smartcontract/sdk/rs/src/commands/link/update.rs b/smartcontract/sdk/rs/src/commands/link/update.rs index 41c8009196..192f6ee516 100644 --- a/smartcontract/sdk/rs/src/commands/link/update.rs +++ b/smartcontract/sdk/rs/src/commands/link/update.rs @@ -101,6 +101,7 @@ impl UpdateLinkCommand { tunnel_net: self.tunnel_net, use_onchain_allocation, link_topologies: None, + unicast_drained: None, }), accounts, ) From 3818674d50a821b4f553e0f9ac2ca33270a0e3ce Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 31 Mar 2026 23:22:35 -0500 Subject: [PATCH 12/49] smartcontract: add include_topologies to Tenant account; foundation-only --- smartcontract/cli/src/accesspass/get.rs | 1 + .../cli/src/tenant/add_administrator.rs | 1 + smartcontract/cli/src/tenant/create.rs | 1 + smartcontract/cli/src/tenant/delete.rs | 3 + smartcontract/cli/src/tenant/get.rs | 1 + smartcontract/cli/src/tenant/list.rs | 1 + .../cli/src/tenant/remove_administrator.rs | 1 + smartcontract/cli/src/tenant/update.rs | 1 + .../cli/src/tenant/update_payment_status.rs | 1 + smartcontract/cli/src/user/get.rs | 1 + smartcontract/cli/src/user/list.rs | 4 + .../src/instructions.rs | 1 + .../src/processors/tenant/create.rs | 1 + .../src/processors/tenant/update.rs | 17 +- .../src/state/tenant.rs | 16 +- .../tests/tenant_test.rs | 283 +++++++++++++++++- .../sdk/rs/src/commands/tenant/delete.rs | 1 + .../sdk/rs/src/commands/tenant/update.rs | 1 + 18 files changed, 328 insertions(+), 8 deletions(-) diff --git a/smartcontract/cli/src/accesspass/get.rs b/smartcontract/cli/src/accesspass/get.rs index 6a3607bf6e..84d9eebfea 100644 --- a/smartcontract/cli/src/accesspass/get.rs +++ b/smartcontract/cli/src/accesspass/get.rs @@ -161,6 +161,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let mgroup_pubkey = Pubkey::new_unique(); diff --git a/smartcontract/cli/src/tenant/add_administrator.rs b/smartcontract/cli/src/tenant/add_administrator.rs index 290c700a23..00065095fb 100644 --- a/smartcontract/cli/src/tenant/add_administrator.rs +++ b/smartcontract/cli/src/tenant/add_administrator.rs @@ -83,6 +83,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client diff --git a/smartcontract/cli/src/tenant/create.rs b/smartcontract/cli/src/tenant/create.rs index 9e73f76c00..36cd3aa167 100644 --- a/smartcontract/cli/src/tenant/create.rs +++ b/smartcontract/cli/src/tenant/create.rs @@ -109,6 +109,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client diff --git a/smartcontract/cli/src/tenant/delete.rs b/smartcontract/cli/src/tenant/delete.rs index ffcab721ad..6252683025 100644 --- a/smartcontract/cli/src/tenant/delete.rs +++ b/smartcontract/cli/src/tenant/delete.rs @@ -252,6 +252,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client @@ -311,6 +312,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client @@ -360,6 +362,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let user = User { diff --git a/smartcontract/cli/src/tenant/get.rs b/smartcontract/cli/src/tenant/get.rs index 323011233c..0b1e82acb7 100644 --- a/smartcontract/cli/src/tenant/get.rs +++ b/smartcontract/cli/src/tenant/get.rs @@ -103,6 +103,7 @@ mod tests { metro_routing: true, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let tenant_cloned = tenant.clone(); diff --git a/smartcontract/cli/src/tenant/list.rs b/smartcontract/cli/src/tenant/list.rs index acc2b72e79..20d06a8e46 100644 --- a/smartcontract/cli/src/tenant/list.rs +++ b/smartcontract/cli/src/tenant/list.rs @@ -91,6 +91,7 @@ mod tests { metro_routing: true, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client diff --git a/smartcontract/cli/src/tenant/remove_administrator.rs b/smartcontract/cli/src/tenant/remove_administrator.rs index 5e9deebbb9..db27bd8483 100644 --- a/smartcontract/cli/src/tenant/remove_administrator.rs +++ b/smartcontract/cli/src/tenant/remove_administrator.rs @@ -83,6 +83,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client diff --git a/smartcontract/cli/src/tenant/update.rs b/smartcontract/cli/src/tenant/update.rs index a304eb559c..fbd8cfc83b 100644 --- a/smartcontract/cli/src/tenant/update.rs +++ b/smartcontract/cli/src/tenant/update.rs @@ -111,6 +111,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client diff --git a/smartcontract/cli/src/tenant/update_payment_status.rs b/smartcontract/cli/src/tenant/update_payment_status.rs index b5843b093e..2d579c4bd8 100644 --- a/smartcontract/cli/src/tenant/update_payment_status.rs +++ b/smartcontract/cli/src/tenant/update_payment_status.rs @@ -84,6 +84,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client diff --git a/smartcontract/cli/src/user/get.rs b/smartcontract/cli/src/user/get.rs index 213171427d..fe2fd9feb5 100644 --- a/smartcontract/cli/src/user/get.rs +++ b/smartcontract/cli/src/user/get.rs @@ -174,6 +174,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let device_pubkey = Pubkey::new_unique(); diff --git a/smartcontract/cli/src/user/list.rs b/smartcontract/cli/src/user/list.rs index d1d01ba371..d4d5703341 100644 --- a/smartcontract/cli/src/user/list.rs +++ b/smartcontract/cli/src/user/list.rs @@ -1421,6 +1421,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let tenant2 = Tenant { @@ -1436,6 +1437,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let user1_pubkey = Pubkey::from_str_const("11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo"); @@ -1590,6 +1592,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let tenant2 = Tenant { @@ -1605,6 +1608,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let user1_pubkey = Pubkey::from_str_const("11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo"); diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index f34015642d..5959946b09 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -1276,6 +1276,7 @@ mod tests { metro_routing: Some(true), route_liveness: Some(false), billing: None, + include_topologies: None, }), "UpdateTenant", ); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/create.rs index 45cb623c6d..8475e08d76 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/create.rs @@ -139,6 +139,7 @@ pub fn process_create_tenant( metro_routing: value.metro_routing, route_liveness: value.route_liveness, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let deposit = Rent::get() diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/update.rs b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/update.rs index 138cf15d07..877b4e0006 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/update.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/update.rs @@ -11,13 +11,11 @@ use borsh_incremental::BorshDeserializeIncremental; use solana_program::{ account_info::{next_account_info, AccountInfo}, entrypoint::ProgramResult, + msg, pubkey::Pubkey, }; use std::fmt; -#[cfg(test)] -use solana_program::msg; - #[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Clone, Default)] pub struct TenantUpdateArgs { pub vrf_id: Option, @@ -25,14 +23,16 @@ pub struct TenantUpdateArgs { pub metro_routing: Option, pub route_liveness: Option, pub billing: Option, + #[incremental(default = None)] + pub include_topologies: Option>, } impl fmt::Debug for TenantUpdateArgs { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "vrf_id: {:?}, token_account: {:?}, metro_routing: {:?}, route_liveness: {:?}, billing: {:?}", - self.vrf_id, self.token_account, self.metro_routing, self.route_liveness, self.billing + "vrf_id: {:?}, token_account: {:?}, metro_routing: {:?}, route_liveness: {:?}, billing: {:?}, include_topologies: {:?}", + self.vrf_id, self.token_account, self.metro_routing, self.route_liveness, self.billing, self.include_topologies ) } } @@ -94,6 +94,13 @@ pub fn process_update_tenant( if let Some(billing) = value.billing { tenant.billing = billing; } + if let Some(ref topologies) = value.include_topologies { + if !globalstate.foundation_allowlist.contains(payer_account.key) { + msg!("TenantUpdate: include_topologies requires foundation key"); + return Err(DoubleZeroError::NotAllowed.into()); + } + tenant.include_topologies = topologies.clone(); + } try_acc_write(&tenant, tenant_account, payer_account, accounts)?; Ok(()) diff --git a/smartcontract/programs/doublezero-serviceability/src/state/tenant.rs b/smartcontract/programs/doublezero-serviceability/src/state/tenant.rs index 588ac6d847..1e58a1f4b2 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/tenant.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/tenant.rs @@ -110,14 +110,22 @@ pub struct Tenant { pub metro_routing: bool, // 1 byte - enables tenant to be routed through metro for VRF requests pub route_liveness: bool, // 1 byte - enables tenant to be check for aliveness before routing pub billing: TenantBillingConfig, // 17 bytes (1 discriminant + 8 rate + 8 last_deduction_dz_epoch) + #[cfg_attr( + feature = "serde", + serde( + serialize_with = "doublezero_program_common::serializer::serialize_pubkeylist_as_string", + deserialize_with = "doublezero_program_common::serializer::deserialize_pubkeylist_from_string" + ) + )] + pub include_topologies: Vec, // 4 + (32 * len) — foundation-only: flex-algo topologies for unicast VPN route steering } impl fmt::Display for Tenant { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "account_type: {}, owner: {}, bump_seed: {}, code: {}, vrf_id: {}, administrators: {:?}, payment_status: {}, token_account: {}, metro_routing: {}, route_liveness: {}, billing: {}", - self.account_type, self.owner, self.bump_seed, self.code, self.vrf_id, self.administrators, self.payment_status, self.token_account, self.metro_routing, self.route_liveness, self.billing + "account_type: {}, owner: {}, bump_seed: {}, code: {}, vrf_id: {}, administrators: {:?}, payment_status: {}, token_account: {}, metro_routing: {}, route_liveness: {}, billing: {}, include_topologies: {:?}", + self.account_type, self.owner, self.bump_seed, self.code, self.vrf_id, self.administrators, self.payment_status, self.token_account, self.metro_routing, self.route_liveness, self.billing, self.include_topologies ) } } @@ -139,6 +147,7 @@ impl TryFrom<&[u8]> for Tenant { metro_routing: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), route_liveness: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), billing: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + include_topologies: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), }; if out.account_type != AccountType::Tenant { @@ -213,6 +222,7 @@ mod tests { metro_routing: true, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let data = borsh::to_vec(&val).unwrap(); @@ -255,6 +265,7 @@ mod tests { metro_routing: true, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let err = val.validate(); assert!(err.is_err()); @@ -276,6 +287,7 @@ mod tests { metro_routing: true, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let err = val.validate(); assert!(err.is_err()); diff --git a/smartcontract/programs/doublezero-serviceability/tests/tenant_test.rs b/smartcontract/programs/doublezero-serviceability/tests/tenant_test.rs index c738acc935..425c3fc9f7 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/tenant_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/tenant_test.rs @@ -9,8 +9,14 @@ use doublezero_serviceability::{ resource::ResourceType, state::{accounttype::AccountType, tenant::*}, }; +use solana_program::instruction::InstructionError; use solana_program_test::*; -use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey}; +use solana_sdk::{ + instruction::AccountMeta, + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::TransactionError, +}; mod test_helpers; use test_helpers::*; @@ -84,6 +90,7 @@ async fn test_tenant() { metro_routing: Some(false), route_liveness: Some(true), billing: None, + include_topologies: None, }), vec![ AccountMeta::new(tenant_pubkey, false), @@ -125,6 +132,7 @@ async fn test_tenant() { metro_routing: None, route_liveness: None, billing: Some(billing_config), + include_topologies: None, }), vec![ AccountMeta::new(tenant_pubkey, false), @@ -563,3 +571,276 @@ async fn test_tenant_remove_nonexistent_administrator_fails() { println!("✅ Nonexistent administrator removal correctly rejected"); println!("🟢🟢🟢 End test_tenant_remove_nonexistent_administrator_fails 🟢🟢🟢"); } + +#[tokio::test] +async fn test_tenant_include_topologies_defaults_to_empty() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + + let tenant_code = "incl-topo-default"; + let (tenant_pubkey, _) = get_tenant_pda(&program_id, tenant_code); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTenant(TenantCreateArgs { + code: tenant_code.to_string(), + administrator: Pubkey::new_unique(), + token_account: None, + metro_routing: false, + route_liveness: false, + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(vrf_ids_pda, false), + ], + &payer, + ) + .await; + + let tenant = get_account_data(&mut banks_client, tenant_pubkey) + .await + .expect("Unable to get Tenant") + .get_tenant() + .unwrap(); + + assert_eq!(tenant.include_topologies, Vec::::new()); + + println!("✅ include_topologies defaults to empty on new Tenant"); +} + +#[tokio::test] +async fn test_tenant_include_topologies_foundation_can_set() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + + let tenant_code = "incl-topo-foundation"; + let (tenant_pubkey, _) = get_tenant_pda(&program_id, tenant_code); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTenant(TenantCreateArgs { + code: tenant_code.to_string(), + administrator: Pubkey::new_unique(), + token_account: None, + metro_routing: false, + route_liveness: false, + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(vrf_ids_pda, false), + ], + &payer, + ) + .await; + + // Foundation key (payer) sets include_topologies + let topology_pubkey = Pubkey::new_unique(); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateTenant(TenantUpdateArgs { + vrf_id: None, + token_account: None, + metro_routing: None, + route_liveness: None, + billing: None, + include_topologies: Some(vec![topology_pubkey]), + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let tenant = get_account_data(&mut banks_client, tenant_pubkey) + .await + .expect("Unable to get Tenant") + .get_tenant() + .unwrap(); + + assert_eq!(tenant.include_topologies, vec![topology_pubkey]); + + println!("✅ Foundation key can set include_topologies"); +} + +#[tokio::test] +async fn test_tenant_include_topologies_non_foundation_rejected() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + + let tenant_code = "incl-topo-nonfoundation"; + let (tenant_pubkey, _) = get_tenant_pda(&program_id, tenant_code); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTenant(TenantCreateArgs { + code: tenant_code.to_string(), + administrator: Pubkey::new_unique(), + token_account: None, + metro_routing: false, + route_liveness: false, + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(vrf_ids_pda, false), + ], + &payer, + ) + .await; + + // A keypair not in the foundation allowlist + let non_foundation = Keypair::new(); + transfer( + &mut banks_client, + &payer, + &non_foundation.pubkey(), + 10_000_000, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateTenant(TenantUpdateArgs { + vrf_id: None, + token_account: None, + metro_routing: None, + route_liveness: None, + billing: None, + include_topologies: Some(vec![Pubkey::new_unique()]), + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &non_foundation, + ) + .await; + + // DoubleZeroError::NotAllowed = Custom(8) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(8), + ))) => {} + _ => panic!("Expected NotAllowed error (Custom(8)), got {:?}", result), + } + + println!("✅ Non-foundation key correctly rejected for include_topologies"); +} + +#[tokio::test] +async fn test_tenant_include_topologies_reset_to_empty() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + + let tenant_code = "incl-topo-reset"; + let (tenant_pubkey, _) = get_tenant_pda(&program_id, tenant_code); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTenant(TenantCreateArgs { + code: tenant_code.to_string(), + administrator: Pubkey::new_unique(), + token_account: None, + metro_routing: false, + route_liveness: false, + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(vrf_ids_pda, false), + ], + &payer, + ) + .await; + + // Set include_topologies to a non-empty list + let topology_pubkey = Pubkey::new_unique(); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateTenant(TenantUpdateArgs { + vrf_id: None, + token_account: None, + metro_routing: None, + route_liveness: None, + billing: None, + include_topologies: Some(vec![topology_pubkey]), + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let tenant = get_account_data(&mut banks_client, tenant_pubkey) + .await + .expect("Unable to get Tenant") + .get_tenant() + .unwrap(); + assert_eq!(tenant.include_topologies, vec![topology_pubkey]); + + // Now reset to empty + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateTenant(TenantUpdateArgs { + vrf_id: None, + token_account: None, + metro_routing: None, + route_liveness: None, + billing: None, + include_topologies: Some(vec![]), + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let tenant = get_account_data(&mut banks_client, tenant_pubkey) + .await + .expect("Unable to get Tenant") + .get_tenant() + .unwrap(); + assert_eq!(tenant.include_topologies, Vec::::new()); + + println!("✅ include_topologies can be reset to empty"); +} diff --git a/smartcontract/sdk/rs/src/commands/tenant/delete.rs b/smartcontract/sdk/rs/src/commands/tenant/delete.rs index 83881419d9..2611e5ed38 100644 --- a/smartcontract/sdk/rs/src/commands/tenant/delete.rs +++ b/smartcontract/sdk/rs/src/commands/tenant/delete.rs @@ -247,6 +247,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let mut seq = Sequence::new(); diff --git a/smartcontract/sdk/rs/src/commands/tenant/update.rs b/smartcontract/sdk/rs/src/commands/tenant/update.rs index cd175fed5c..b0b511e37d 100644 --- a/smartcontract/sdk/rs/src/commands/tenant/update.rs +++ b/smartcontract/sdk/rs/src/commands/tenant/update.rs @@ -26,6 +26,7 @@ impl UpdateTenantCommand { metro_routing: self.metro_routing, route_liveness: self.route_liveness, billing: self.billing, + include_topologies: None, }), vec![ AccountMeta::new(self.tenant_pubkey, false), From 6ab6b7d346a7e3af0fac2733dd8137df481f81a8 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 31 Mar 2026 23:36:37 -0500 Subject: [PATCH 13/49] smartcontract: fix include_topologies test assert and fixture generator --- .../testdata/fixtures/generate-fixtures/src/main.rs | 1 + .../programs/doublezero-serviceability/src/state/tenant.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs index 2ab9f9ae70..be3d969d5f 100644 --- a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs +++ b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs @@ -754,6 +754,7 @@ fn generate_tenant(dir: &Path) { metro_routing: true, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let data = borsh::to_vec(&val).unwrap(); diff --git a/smartcontract/programs/doublezero-serviceability/src/state/tenant.rs b/smartcontract/programs/doublezero-serviceability/src/state/tenant.rs index 1e58a1f4b2..7923e22c39 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/tenant.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/tenant.rs @@ -205,6 +205,7 @@ mod tests { assert_eq!(val.administrators, Vec::::new()); assert_eq!(val.payment_status, TenantPaymentStatus::Delinquent); assert_eq!(val.token_account, Pubkey::default()); + assert_eq!(val.include_topologies, Vec::::new()); } #[test] From 396bbf09e1c60bb93adaa64ec830b158a8657e7f Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 00:13:07 -0500 Subject: [PATCH 14/49] smartcontract: auto-tag UNICAST-DEFAULT topology at link activation --- .../src/processors/link/activate.rs | 18 +- .../tests/global_test.rs | 10 + .../tests/link_dzx_test.rs | 11 + .../tests/link_onchain_allocation_test.rs | 35 ++- .../tests/link_wan_test.rs | 200 ++++++++++++++++++ .../tests/test_helpers.rs | 63 +++++- .../tests/topology_test.rs | 116 ++++++++-- .../tests/unlink_device_interface_test.rs | 10 + 8 files changed, 441 insertions(+), 22 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs index d3f246259a..ddefd0adae 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs @@ -1,6 +1,6 @@ use crate::{ error::DoubleZeroError, - pda::get_resource_extension_pda, + pda::{get_resource_extension_pda, get_topology_pda}, processors::resource::{allocate_id, allocate_ip}, resource::ResourceType, serializer::try_acc_write, @@ -56,11 +56,11 @@ pub fn process_activate_link( let side_z_device_account = next_account_info(accounts_iter)?; let globalstate_account = next_account_info(accounts_iter)?; - // Optional: ResourceExtension accounts for on-chain allocation (before payer) + // Optional: ResourceExtension accounts for on-chain allocation (before unicast-default topology) // Account layout WITH ResourceExtension (use_onchain_allocation = true): - // [link, side_a_dev, side_z_dev, globalstate, device_tunnel_block, link_ids, payer, system] + // [link, side_a_dev, side_z_dev, globalstate, device_tunnel_block, link_ids, unicast_default, payer, system] // Account layout WITHOUT (legacy, use_onchain_allocation = false): - // [link, side_a_dev, side_z_dev, globalstate, payer, system] + // [link, side_a_dev, side_z_dev, globalstate, unicast_default, payer, system] let resource_extension_accounts = if value.use_onchain_allocation { let device_tunnel_block_ext = next_account_info(accounts_iter)?; // DeviceTunnelBlock (global) let link_ids_ext = next_account_info(accounts_iter)?; // LinkIds (global) @@ -69,6 +69,7 @@ pub fn process_activate_link( None }; + let unicast_default_topology_account = next_account_info(accounts_iter)?; let payer_account = next_account_info(accounts_iter)?; let _system_program = next_account_info(accounts_iter)?; @@ -232,6 +233,15 @@ pub fn process_activate_link( link.check_status_transition(); + // Auto-tag with UNICAST-DEFAULT topology at activation + let (expected_unicast_default_pda, _) = get_topology_pda(program_id, "unicast-default"); + if unicast_default_topology_account.key != &expected_unicast_default_pda + || unicast_default_topology_account.data_is_empty() + { + return Err(DoubleZeroError::InvalidArgument.into()); + } + link.link_topologies = vec![*unicast_default_topology_account.key]; + try_acc_write(&side_a_dev, side_a_device_account, payer_account, accounts)?; try_acc_write(&side_z_dev, side_z_device_account, payer_account, accounts)?; try_acc_write(&link, link_account, payer_account, accounts)?; diff --git a/smartcontract/programs/doublezero-serviceability/tests/global_test.rs b/smartcontract/programs/doublezero-serviceability/tests/global_test.rs index 8589a4b1c8..6bdd7ccc26 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/global_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/global_test.rs @@ -693,6 +693,15 @@ async fn test_doublezero_program() { use_onchain_allocation: false, }; + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + execute_transaction( &mut banks_client, recent_blockhash, @@ -703,6 +712,7 @@ async fn test_doublezero_program() { AccountMeta::new(device_la_pubkey, false), AccountMeta::new(device_ny_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs index 99f1ba2d33..e9fb4abfa3 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs @@ -614,6 +614,15 @@ async fn test_dzx_link() { /*****************************************************************************************************************************************************/ println!("🟢 13. Activate Link..."); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + config_pubkey, + &payer, + ) + .await; + // Regression: activation must fail if side A/Z accounts do not match link.side_{a,z}_pk let res = try_execute_transaction( &mut banks_client, @@ -630,6 +639,7 @@ async fn test_dzx_link() { AccountMeta::new(device_z_pubkey, false), AccountMeta::new(device_a_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -652,6 +662,7 @@ async fn test_dzx_link() { AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs index 2cb6da408c..334e7549b8 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs @@ -38,7 +38,7 @@ use test_helpers::*; /// Test that ActivateLink works with onchain allocation from ResourceExtension #[tokio::test] async fn test_activate_link_with_onchain_allocation() { - let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = setup_program_with_globalconfig().await; let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); @@ -382,6 +382,15 @@ async fn test_activate_link_with_onchain_allocation() { // Activate Link with onchain allocation println!("Activating Link with onchain allocation..."); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + execute_transaction( &mut banks_client, recent_blockhash, @@ -398,6 +407,7 @@ async fn test_activate_link_with_onchain_allocation() { AccountMeta::new(globalstate_pubkey, false), AccountMeta::new(device_tunnel_block_pda, false), AccountMeta::new(link_ids_pda, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -441,7 +451,7 @@ async fn test_activate_link_with_onchain_allocation() { /// Test that the legacy ActivateLink path (without onchain allocation) still works #[tokio::test] async fn test_activate_link_legacy_path() { - let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = setup_program_with_globalconfig().await; let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); @@ -725,6 +735,15 @@ async fn test_activate_link_legacy_path() { let expected_tunnel_net: doublezero_program_common::types::NetworkV4 = "10.0.0.0/21".parse().unwrap(); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + execute_transaction( &mut banks_client, recent_blockhash, @@ -739,6 +758,7 @@ async fn test_activate_link_legacy_path() { AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -1036,6 +1056,16 @@ async fn test_closeaccount_link_with_deallocation() { get_resource_extension_pda(&program_id, ResourceType::DeviceTunnelBlock); let (link_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::LinkIds); + // Create unicast-default topology (required for link activation) + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + // Activate Link with onchain allocation execute_transaction( &mut banks_client, @@ -1053,6 +1083,7 @@ async fn test_closeaccount_link_with_deallocation() { AccountMeta::new(globalstate_pubkey, false), AccountMeta::new(device_tunnel_block_pda, false), AccountMeta::new(link_ids_pda, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index bb13dbad51..5589bf89eb 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -5,6 +5,8 @@ use doublezero_serviceability::{ contributor::create::ContributorCreateArgs, device::interface::{update::DeviceInterfaceUpdateArgs, DeviceInterfaceUnlinkArgs}, link::{activate::*, create::*, delete::*, sethealth::LinkSetHealthArgs, update::*}, + resource::create::ResourceCreateArgs, + topology::create::TopologyCreateArgs, *, }, resource::ResourceType, @@ -16,6 +18,7 @@ use doublezero_serviceability::{ InterfaceCYOA, InterfaceDIA, InterfaceStatus, InterfaceType, LoopbackType, RoutingMode, }, link::*, + topology::TopologyConstraint, }, }; use globalconfig::set::SetGlobalConfigArgs; @@ -556,6 +559,15 @@ async fn test_wan_link() { /*****************************************************************************************************************************************************/ println!("🟢 8. Activate Link..."); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + config_pubkey, + &payer, + ) + .await; + execute_transaction( &mut banks_client, recent_blockhash, @@ -570,6 +582,7 @@ async fn test_wan_link() { AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -1399,6 +1412,15 @@ async fn test_wan_link_rejects_cyoa_interface() { ) .await; + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + config_pubkey, + &payer, + ) + .await; + // Attempt to activate the link - should fail because side A now has CYOA let res = try_execute_transaction( &mut banks_client, @@ -1414,6 +1436,7 @@ async fn test_wan_link_rejects_cyoa_interface() { AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -1766,6 +1789,15 @@ async fn test_cannot_set_cyoa_on_linked_interface() { ) .await; + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + config_pubkey, + &payer, + ) + .await; + execute_transaction( &mut banks_client, recent_blockhash, @@ -1780,6 +1812,7 @@ async fn test_cannot_set_cyoa_on_linked_interface() { AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -2291,6 +2324,16 @@ async fn test_link_delete_from_soft_drained() { .await .expect("Failed to get blockhash"); + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + // Activate execute_transaction( &mut banks_client, @@ -2306,6 +2349,7 @@ async fn test_link_delete_from_soft_drained() { AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -2414,6 +2458,16 @@ async fn test_link_delete_from_hard_drained() { .await .expect("Failed to get blockhash"); + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + // Activate execute_transaction( &mut banks_client, @@ -2429,6 +2483,7 @@ async fn test_link_delete_from_hard_drained() { AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -2486,3 +2541,148 @@ async fn test_link_delete_from_hard_drained() { .unwrap(); assert_eq!(link.status, LinkStatus::Deleting); } + +#[tokio::test] +async fn test_link_activation_auto_tags_unicast_default() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + _contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let recent_blockhash = banks_client + .get_latest_blockhash() + .await + .expect("Failed to get blockhash"); + + // Create AdminGroupBits resource extension (required before creating topology) + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateResource(ResourceCreateArgs { + resource_type: ResourceType::AdminGroupBits, + }), + vec![ + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new(solana_sdk::pubkey::Pubkey::default(), false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + ], + &payer, + ) + .await; + + // Create the "unicast-default" topology + let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(unicast_default_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Activate the link — it should auto-tag with UNICAST-DEFAULT + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + // Verify link_topologies was set to [unicast_default_pda] + let link = get_account_data(&mut banks_client, tunnel_pubkey) + .await + .expect("Link not found") + .get_tunnel() + .unwrap(); + assert_eq!(link.status, LinkStatus::Activated); + assert_eq!( + link.link_topologies, + vec![unicast_default_pda], + "link.link_topologies should be [unicast-default PDA] after activation" + ); +} + +#[tokio::test] +async fn test_link_activation_fails_without_unicast_default() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + _contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let recent_blockhash = banks_client + .get_latest_blockhash() + .await + .expect("Failed to get blockhash"); + + // Derive the unicast-default PDA without creating it + let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + // Attempt to activate — should fail with InvalidArgument (Custom(65)) + let result = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + let error_string = format!("{:?}", result.unwrap_err()); + assert!( + error_string.contains("Custom(65)"), + "Expected InvalidArgument (Custom(65)), got: {}", + error_string + ); +} diff --git a/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs b/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs index 7ce9d55cd1..025a337dcc 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs @@ -4,13 +4,17 @@ use doublezero_serviceability::{ instructions::*, pda::{ get_globalconfig_pda, get_globalstate_pda, get_program_config_pda, - get_resource_extension_pda, + get_resource_extension_pda, get_topology_pda, + }, + processors::{ + globalconfig::set::SetGlobalConfigArgs, resource::create::ResourceCreateArgs, + topology::create::TopologyCreateArgs, }, - processors::globalconfig::set::SetGlobalConfigArgs, resource::ResourceType, state::{ accountdata::AccountData, accounttype::AccountType, device::Device, globalstate::GlobalState, resource_extension::ResourceExtensionOwned, + topology::TopologyConstraint, }, }; use solana_program_test::*; @@ -461,3 +465,58 @@ pub async fn setup_program_with_globalconfig() -> (BanksClient, Keypair, Pubkey, globalconfig_pubkey, ) } + +/// Create the AdminGroupBits resource extension and the "unicast-default" topology. +/// Returns the PDA of the "unicast-default" topology. +/// Requires that global state + global config are already initialized. +#[allow(dead_code)] +pub async fn create_unicast_default_topology( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + globalconfig_pubkey: Pubkey, + payer: &Keypair, +) -> Pubkey { + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateResource(ResourceCreateArgs { + resource_type: ResourceType::AdminGroupBits, + }), + vec![ + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new(Pubkey::default(), false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + ], + payer, + ) + .await; + + let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(unicast_default_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + payer, + ) + .await; + + unicast_default_pda +} diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index e62d1380cb..fcc12595dd 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -1109,7 +1109,8 @@ async fn setup_wan_link( ) .await; - // Activate link + // Activate link (unicast-default topology must already exist at this point) + let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); execute_transaction( banks_client, recent_blockhash, @@ -1124,6 +1125,7 @@ async fn setup_wan_link( AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], payer, ) @@ -1250,6 +1252,18 @@ async fn test_topology_delete_fails_when_link_references_it() { ) .await; + // Create unicast-default topology (required for link activation) + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + // Set up a WAN link and assign the topology to it let (link_pubkey, contributor_pubkey, _, _) = setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; @@ -1388,6 +1402,18 @@ async fn test_topology_clear_removes_from_links() { ) .await; + // Create unicast-default topology (required for link activation) + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + // Set up a WAN link and assign the topology to it let (link_pubkey, contributor_pubkey, _, _) = setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; @@ -1445,7 +1471,7 @@ async fn test_topology_clear_is_idempotent() { ) .await; - create_topology( + let test_topology_pda = create_topology( &mut banks_client, program_id, globalstate_pubkey, @@ -1456,15 +1482,37 @@ async fn test_topology_clear_is_idempotent() { ) .await; - // Set up a WAN link but do NOT assign the topology + // Create unicast-default topology (required for link activation) + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + // Set up a WAN link but do NOT assign the "test-topology" topology let (link_pubkey, _, _, _) = setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; - // Verify link has no topology assignment + // Verify link has only the unicast-default topology (auto-tagged at activation), + // NOT the "test-topology" topology let link = get_link(&mut banks_client, link_pubkey).await; - assert!(link.link_topologies.is_empty()); + assert_eq!( + link.link_topologies, + vec![unicast_default_pda], + "link_topologies should only contain unicast-default after activation" + ); + assert!( + !link.link_topologies.contains(&test_topology_pda), + "link_topologies should not contain test-topology" + ); - // Call clear — link does not reference topology, so nothing should change, no error + // Call clear — link does not reference "test-topology", so nothing should change, no error clear_topology( &mut banks_client, program_id, @@ -1475,11 +1523,12 @@ async fn test_topology_clear_is_idempotent() { ) .await; - // Verify link is still empty + // Verify link_topologies is unchanged (still only unicast-default) let link = get_link(&mut banks_client, link_pubkey).await; - assert!( - link.link_topologies.is_empty(), - "link_topologies should still be empty" + assert_eq!( + link.link_topologies, + vec![unicast_default_pda], + "link_topologies should still only contain unicast-default after no-op clear" ); println!("[PASS] test_topology_clear_is_idempotent"); @@ -1624,9 +1673,18 @@ async fn test_topology_clear_non_foundation_rejected() { async fn test_link_unicast_drained_contributor_can_set_own_link() { println!("[TEST] test_link_unicast_drained_contributor_can_set_own_link"); - let (mut banks_client, payer, program_id, globalstate_pubkey, _) = + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = setup_program_with_globalconfig().await; + create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + let (link_pubkey, contributor_pubkey, _, _) = setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; @@ -1664,9 +1722,18 @@ async fn test_link_unicast_drained_contributor_can_set_own_link() { async fn test_link_unicast_drained_contributor_cannot_set_other_link() { println!("[TEST] test_link_unicast_drained_contributor_cannot_set_other_link"); - let (mut banks_client, payer, program_id, globalstate_pubkey, _) = + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = setup_program_with_globalconfig().await; + create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + // Create the link owned by payer (contributor A) let (link_pubkey, _contributor_a_pubkey, _, _) = setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; @@ -1731,9 +1798,18 @@ async fn test_link_unicast_drained_contributor_cannot_set_other_link() { async fn test_link_unicast_drained_foundation_can_set_any_link() { println!("[TEST] test_link_unicast_drained_foundation_can_set_any_link"); - let (mut banks_client, payer, program_id, globalstate_pubkey, _) = + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = setup_program_with_globalconfig().await; + create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + let (link_pubkey, contributor_pubkey, _, _) = setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; @@ -1789,10 +1865,22 @@ async fn test_link_unicast_drained_orthogonal_to_status_and_topologies() { ) .await; + // Create unicast-default topology (required for link activation) + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + let (link_pubkey, contributor_pubkey, _, _) = setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; - // Assign a topology to the link (foundation-only) + // Assign a topology to the link (foundation-only), replacing the unicast-default auto-tag assign_link_topology( &mut banks_client, program_id, diff --git a/smartcontract/programs/doublezero-serviceability/tests/unlink_device_interface_test.rs b/smartcontract/programs/doublezero-serviceability/tests/unlink_device_interface_test.rs index b02615bf67..3d0d2c8829 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/unlink_device_interface_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/unlink_device_interface_test.rs @@ -342,6 +342,15 @@ async fn setup_two_devices_with_link() -> ( .await; // Activate the link (interfaces become Activated with tunnel IPs) + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + config_pubkey, + &payer, + ) + .await; + execute_transaction( &mut banks_client, recent_blockhash, @@ -356,6 +365,7 @@ async fn setup_two_devices_with_link() -> ( AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) From 133cfff56b3a9a5a6416f27c82bf25a2a9e640b8 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 08:12:38 -0500 Subject: [PATCH 15/49] smartcontract: fix activate: add unicast-default owner check; refactor test helper usage --- .../src/processors/link/activate.rs | 5 ++ .../tests/link_wan_test.rs | 53 ++++--------------- 2 files changed, 16 insertions(+), 42 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs index ddefd0adae..a87b02d331 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs @@ -234,6 +234,11 @@ pub fn process_activate_link( link.check_status_transition(); // Auto-tag with UNICAST-DEFAULT topology at activation + assert_eq!( + unicast_default_topology_account.owner, + program_id, + "Invalid UNICAST-DEFAULT topology account owner" + ); let (expected_unicast_default_pda, _) = get_topology_pda(program_id, "unicast-default"); if unicast_default_topology_account.key != &expected_unicast_default_pda || unicast_default_topology_account.data_is_empty() diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index 5589bf89eb..f7d7dea1fc 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -2555,52 +2555,20 @@ async fn test_link_activation_auto_tags_unicast_default() { tunnel_pubkey, ) = setup_link_env().await; - let recent_blockhash = banks_client - .get_latest_blockhash() - .await - .expect("Failed to get blockhash"); - - // Create AdminGroupBits resource extension (required before creating topology) - let (admin_group_bits_pda, _, _) = - get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); - - execute_transaction( + let unicast_default_pda = create_unicast_default_topology( &mut banks_client, - recent_blockhash, program_id, - DoubleZeroInstruction::CreateResource(ResourceCreateArgs { - resource_type: ResourceType::AdminGroupBits, - }), - vec![ - AccountMeta::new(admin_group_bits_pda, false), - AccountMeta::new(solana_sdk::pubkey::Pubkey::default(), false), - AccountMeta::new(globalstate_pubkey, false), - AccountMeta::new(globalconfig_pubkey, false), - ], + globalstate_pubkey, + globalconfig_pubkey, &payer, ) .await; - // Create the "unicast-default" topology - let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); - - execute_transaction( - &mut banks_client, - recent_blockhash, - program_id, - DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { - name: "unicast-default".to_string(), - constraint: TopologyConstraint::IncludeAny, - }), - vec![ - AccountMeta::new(unicast_default_pda, false), - AccountMeta::new(admin_group_bits_pda, false), - AccountMeta::new_readonly(globalstate_pubkey, false), - ], - &payer, - ) - .await; + let recent_blockhash = banks_client + .get_latest_blockhash() + .await + .expect("Failed to get blockhash"); // Activate the link — it should auto-tag with UNICAST-DEFAULT execute_transaction( @@ -2658,7 +2626,8 @@ async fn test_link_activation_fails_without_unicast_default() { // Derive the unicast-default PDA without creating it let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); - // Attempt to activate — should fail with InvalidArgument (Custom(65)) + // Attempt to activate — should fail because the unicast-default account is + // system-owned (not created), triggering the owner check before key validation. let result = try_execute_transaction( &mut banks_client, recent_blockhash, @@ -2681,8 +2650,8 @@ async fn test_link_activation_fails_without_unicast_default() { let error_string = format!("{:?}", result.unwrap_err()); assert!( - error_string.contains("Custom(65)"), - "Expected InvalidArgument (Custom(65)), got: {}", + error_string.contains("ProgramFailedToComplete"), + "Expected ProgramFailedToComplete (owner check), got: {}", error_string ); } From b481271c163dff24680087f014729de540b2511d Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 08:17:43 -0500 Subject: [PATCH 16/49] smartcontract: fix activate: fold owner check into InvalidArgument guard; restore Custom(65) test assertion --- .../src/processors/link/activate.rs | 8 ++------ .../doublezero-serviceability/tests/link_wan_test.rs | 4 ++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs index a87b02d331..77a0c5ad3e 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs @@ -234,13 +234,9 @@ pub fn process_activate_link( link.check_status_transition(); // Auto-tag with UNICAST-DEFAULT topology at activation - assert_eq!( - unicast_default_topology_account.owner, - program_id, - "Invalid UNICAST-DEFAULT topology account owner" - ); let (expected_unicast_default_pda, _) = get_topology_pda(program_id, "unicast-default"); - if unicast_default_topology_account.key != &expected_unicast_default_pda + if unicast_default_topology_account.owner != program_id + || unicast_default_topology_account.key != &expected_unicast_default_pda || unicast_default_topology_account.data_is_empty() { return Err(DoubleZeroError::InvalidArgument.into()); diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index f7d7dea1fc..95b413868c 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -2650,8 +2650,8 @@ async fn test_link_activation_fails_without_unicast_default() { let error_string = format!("{:?}", result.unwrap_err()); assert!( - error_string.contains("ProgramFailedToComplete"), - "Expected ProgramFailedToComplete (owner check), got: {}", + error_string.contains("Custom(65)"), + "Expected InvalidArgument error (Custom(65)), got: {}", error_string ); } From 98d968b7b167e6d0fe675fb5f7b69d9ae97402b8 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 09:12:29 -0500 Subject: [PATCH 17/49] =?UTF-8?q?smartcontract:=20fix=20lint=20=E2=80=94?= =?UTF-8?q?=20missing=20include=5Ftopologies=20fields,=20unused=20imports,?= =?UTF-8?q?=20unused=20variables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/doublezero/src/command/connect.rs | 2 ++ .../testdata/fixtures/generate-fixtures/Cargo.lock | 4 ++-- .../tests/link_onchain_allocation_test.rs | 4 ++-- .../programs/doublezero-serviceability/tests/link_wan_test.rs | 3 --- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/client/doublezero/src/command/connect.rs b/client/doublezero/src/command/connect.rs index cb5ea306bf..c09054803d 100644 --- a/client/doublezero/src/command/connect.rs +++ b/client/doublezero/src/command/connect.rs @@ -1072,6 +1072,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let mut tenants = HashMap::new(); @@ -1397,6 +1398,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; tenants.insert(pk, tenant.clone()); (pk, tenant) diff --git a/sdk/serviceability/testdata/fixtures/generate-fixtures/Cargo.lock b/sdk/serviceability/testdata/fixtures/generate-fixtures/Cargo.lock index 0d9d791f9e..6430182615 100644 --- a/sdk/serviceability/testdata/fixtures/generate-fixtures/Cargo.lock +++ b/sdk/serviceability/testdata/fixtures/generate-fixtures/Cargo.lock @@ -346,7 +346,7 @@ dependencies = [ [[package]] name = "doublezero-program-common" -version = "0.12.0" +version = "0.15.0" dependencies = [ "borsh 1.6.0", "byteorder", @@ -358,7 +358,7 @@ dependencies = [ [[package]] name = "doublezero-serviceability" -version = "0.12.0" +version = "0.15.0" dependencies = [ "bitflags", "borsh 1.6.0", diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs index 334e7549b8..dea3a48589 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs @@ -38,7 +38,7 @@ use test_helpers::*; /// Test that ActivateLink works with onchain allocation from ResourceExtension #[tokio::test] async fn test_activate_link_with_onchain_allocation() { - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); @@ -451,7 +451,7 @@ async fn test_activate_link_with_onchain_allocation() { /// Test that the legacy ActivateLink path (without onchain allocation) still works #[tokio::test] async fn test_activate_link_legacy_path() { - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index 95b413868c..f4bb52209c 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -5,8 +5,6 @@ use doublezero_serviceability::{ contributor::create::ContributorCreateArgs, device::interface::{update::DeviceInterfaceUpdateArgs, DeviceInterfaceUnlinkArgs}, link::{activate::*, create::*, delete::*, sethealth::LinkSetHealthArgs, update::*}, - resource::create::ResourceCreateArgs, - topology::create::TopologyCreateArgs, *, }, resource::ResourceType, @@ -18,7 +16,6 @@ use doublezero_serviceability::{ InterfaceCYOA, InterfaceDIA, InterfaceStatus, InterfaceType, LoopbackType, RoutingMode, }, link::*, - topology::TopologyConstraint, }, }; use globalconfig::set::SetGlobalConfigArgs; From 19b435f66095b7cb88ed6a3041e3c34c51a0bafb Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 09:50:32 -0500 Subject: [PATCH 18/49] cli: add doublezero link topology create/delete/clear/list subcommands --- client/doublezero/src/cli/link.rs | 35 +++++- client/doublezero/src/main.rs | 8 +- smartcontract/cli/src/doublezerocommand.rs | 30 ++++- smartcontract/cli/src/lib.rs | 1 + smartcontract/cli/src/topology/clear.rs | 114 +++++++++++++++++ smartcontract/cli/src/topology/create.rs | 100 +++++++++++++++ smartcontract/cli/src/topology/delete.rs | 57 +++++++++ smartcontract/cli/src/topology/list.rs | 99 +++++++++++++++ smartcontract/cli/src/topology/mod.rs | 4 + .../sdk/rs/src/commands/link/activate.rs | 20 ++- smartcontract/sdk/rs/src/commands/mod.rs | 1 + .../sdk/rs/src/commands/topology/clear.rs | 115 ++++++++++++++++++ .../sdk/rs/src/commands/topology/create.rs | 93 ++++++++++++++ .../sdk/rs/src/commands/topology/delete.rs | 74 +++++++++++ .../sdk/rs/src/commands/topology/list.rs | 85 +++++++++++++ .../sdk/rs/src/commands/topology/mod.rs | 4 + smartcontract/sdk/rs/src/lib.rs | 3 +- 17 files changed, 833 insertions(+), 10 deletions(-) create mode 100644 smartcontract/cli/src/topology/clear.rs create mode 100644 smartcontract/cli/src/topology/create.rs create mode 100644 smartcontract/cli/src/topology/delete.rs create mode 100644 smartcontract/cli/src/topology/list.rs create mode 100644 smartcontract/cli/src/topology/mod.rs create mode 100644 smartcontract/sdk/rs/src/commands/topology/clear.rs create mode 100644 smartcontract/sdk/rs/src/commands/topology/create.rs create mode 100644 smartcontract/sdk/rs/src/commands/topology/delete.rs create mode 100644 smartcontract/sdk/rs/src/commands/topology/list.rs create mode 100644 smartcontract/sdk/rs/src/commands/topology/mod.rs diff --git a/client/doublezero/src/cli/link.rs b/client/doublezero/src/cli/link.rs index 86b41ac6b0..8e898db2f4 100644 --- a/client/doublezero/src/cli/link.rs +++ b/client/doublezero/src/cli/link.rs @@ -1,8 +1,14 @@ use clap::{Args, Subcommand}; -use doublezero_cli::link::{ - accept::AcceptLinkCliCommand, delete::*, dzx_create::CreateDZXLinkCliCommand, get::*, - latency::LinkLatencyCliCommand, list::*, sethealth::SetLinkHealthCliCommand, update::*, - wan_create::*, +use doublezero_cli::{ + link::{ + accept::AcceptLinkCliCommand, delete::*, dzx_create::CreateDZXLinkCliCommand, get::*, + latency::LinkLatencyCliCommand, list::*, sethealth::SetLinkHealthCliCommand, update::*, + wan_create::*, + }, + topology::{ + clear::ClearTopologyCliCommand, create::CreateTopologyCliCommand, + delete::DeleteTopologyCliCommand, list::ListTopologyCliCommand, + }, }; #[derive(Args, Debug)] @@ -53,4 +59,25 @@ pub enum LinkCommands { // Hidden because this is an internal/operational command not intended for general CLI users. #[clap(hide = true)] SetHealth(SetLinkHealthCliCommand), + /// Manage link topologies + #[clap()] + Topology(TopologyLinkCommand), +} + +#[derive(Args, Debug)] +pub struct TopologyLinkCommand { + #[command(subcommand)] + pub command: TopologyCommands, +} + +#[derive(Debug, Subcommand)] +pub enum TopologyCommands { + /// Create a new topology + Create(CreateTopologyCliCommand), + /// Delete a topology + Delete(DeleteTopologyCliCommand), + /// Clear a topology from links + Clear(ClearTopologyCliCommand), + /// List all topologies + List(ListTopologyCliCommand), } diff --git a/client/doublezero/src/main.rs b/client/doublezero/src/main.rs index 17f5dbc8ed..d5437e422b 100644 --- a/client/doublezero/src/main.rs +++ b/client/doublezero/src/main.rs @@ -17,7 +17,7 @@ use crate::cli::{ AirdropCommands, AuthorityCommands, FeatureFlagsCommands, FoundationAllowlistCommands, GlobalConfigCommands, QaAllowlistCommands, }, - link::LinkCommands, + link::{LinkCommands, TopologyCommands}, location::LocationCommands, user::UserCommands, }; @@ -232,6 +232,12 @@ async fn main() -> eyre::Result<()> { LinkCommands::Latency(args) => args.execute(&client, &mut handle), LinkCommands::Delete(args) => args.execute(&client, &mut handle), LinkCommands::SetHealth(args) => args.execute(&client, &mut handle), + LinkCommands::Topology(args) => match args.command { + TopologyCommands::Create(args) => args.execute(&client, &mut handle), + TopologyCommands::Delete(args) => args.execute(&client, &mut handle), + TopologyCommands::Clear(args) => args.execute(&client, &mut handle), + TopologyCommands::List(args) => args.execute(&client, &mut handle), + }, }, Command::AccessPass(command) => match command.command { cli::accesspass::AccessPassCommands::Set(args) => args.execute(&client, &mut handle), diff --git a/smartcontract/cli/src/doublezerocommand.rs b/smartcontract/cli/src/doublezerocommand.rs index d9ce860377..1b1eab3126 100644 --- a/smartcontract/cli/src/doublezerocommand.rs +++ b/smartcontract/cli/src/doublezerocommand.rs @@ -99,6 +99,10 @@ use doublezero_sdk::{ remove_administrator::RemoveAdministratorTenantCommand, update::UpdateTenantCommand, update_payment_status::UpdatePaymentStatusCommand, }, + topology::{ + clear::ClearTopologyCommand, create::CreateTopologyCommand, + delete::DeleteTopologyCommand, list::ListTopologyCommand, + }, user::{ create::CreateUserCommand, create_subscribe::CreateSubscribeUserCommand, delete::DeleteUserCommand, get::GetUserCommand, list::ListUserCommand, @@ -107,7 +111,8 @@ use doublezero_sdk::{ }, telemetry::LinkLatencyStats, DZClient, Device, DoubleZeroClient, Exchange, GetGlobalConfigCommand, GetGlobalStateCommand, - GlobalConfig, GlobalState, Link, Location, MulticastGroup, ResourceExtensionOwned, User, + GlobalConfig, GlobalState, Link, Location, MulticastGroup, ResourceExtensionOwned, + TopologyInfo, User, }; use doublezero_serviceability::state::{ accesspass::AccessPass, accountdata::AccountData, contributor::Contributor, @@ -336,6 +341,14 @@ pub trait CliCommand { cmd: GetResourceCommand, ) -> eyre::Result<(Pubkey, ResourceExtensionOwned)>; fn close_resource(&self, cmd: CloseResourceCommand) -> eyre::Result; + + fn create_topology(&self, cmd: CreateTopologyCommand) -> eyre::Result<(Signature, Pubkey)>; + fn delete_topology(&self, cmd: DeleteTopologyCommand) -> eyre::Result; + fn clear_topology(&self, cmd: ClearTopologyCommand) -> eyre::Result; + fn list_topology( + &self, + cmd: ListTopologyCommand, + ) -> eyre::Result>; } pub struct CliCommandImpl<'a> { @@ -799,4 +812,19 @@ impl CliCommand for CliCommandImpl<'_> { fn close_resource(&self, cmd: CloseResourceCommand) -> eyre::Result { cmd.execute(self.client) } + fn create_topology(&self, cmd: CreateTopologyCommand) -> eyre::Result<(Signature, Pubkey)> { + cmd.execute(self.client) + } + fn delete_topology(&self, cmd: DeleteTopologyCommand) -> eyre::Result { + cmd.execute(self.client) + } + fn clear_topology(&self, cmd: ClearTopologyCommand) -> eyre::Result { + cmd.execute(self.client) + } + fn list_topology( + &self, + cmd: ListTopologyCommand, + ) -> eyre::Result> { + cmd.execute(self.client) + } } diff --git a/smartcontract/cli/src/lib.rs b/smartcontract/cli/src/lib.rs index f9d4118273..4f330c9158 100644 --- a/smartcontract/cli/src/lib.rs +++ b/smartcontract/cli/src/lib.rs @@ -30,6 +30,7 @@ pub mod resource; pub mod subscribe; pub mod tenant; pub mod tests; +pub mod topology; pub mod user; pub mod util; pub mod validators; diff --git a/smartcontract/cli/src/topology/clear.rs b/smartcontract/cli/src/topology/clear.rs new file mode 100644 index 0000000000..81a925a614 --- /dev/null +++ b/smartcontract/cli/src/topology/clear.rs @@ -0,0 +1,114 @@ +use crate::{ + doublezerocommand::CliCommand, + requirements::{CHECK_BALANCE, CHECK_ID_JSON}, +}; +use clap::Args; +use doublezero_sdk::commands::topology::clear::ClearTopologyCommand; +use solana_sdk::pubkey::Pubkey; +use std::io::Write; + +#[derive(Args, Debug)] +pub struct ClearTopologyCliCommand { + /// Name of the topology to clear from links + #[arg(long)] + pub name: String, + /// Comma-separated list of link pubkeys to clear the topology from + #[arg(long, value_delimiter = ',')] + pub links: Vec, +} + +impl ClearTopologyCliCommand { + pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { + client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; + + let link_pubkeys: Vec = self + .links + .iter() + .map(|s| { + s.parse::() + .map_err(|_| eyre::eyre!("invalid link pubkey: {}", s)) + }) + .collect::>>()?; + + let n = link_pubkeys.len(); + client.clear_topology(ClearTopologyCommand { + name: self.name.clone(), + link_pubkeys, + })?; + writeln!(out, "Cleared topology '{}' from {} link(s).", self.name, n)?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::doublezerocommand::MockCliCommand; + use mockall::predicate::eq; + use solana_sdk::{pubkey::Pubkey, signature::Signature}; + use std::io::Cursor; + + #[test] + fn test_clear_topology_execute_no_links() { + let mut mock = MockCliCommand::new(); + + mock.expect_check_requirements().returning(|_| Ok(())); + mock.expect_clear_topology() + .with(eq(ClearTopologyCommand { + name: "unicast-default".to_string(), + link_pubkeys: vec![], + })) + .returning(|_| Ok(Signature::new_unique())); + + let cmd = ClearTopologyCliCommand { + name: "unicast-default".to_string(), + links: vec![], + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + assert!(output.contains("Cleared topology 'unicast-default' from 0 link(s).")); + } + + #[test] + fn test_clear_topology_execute_with_links() { + let mut mock = MockCliCommand::new(); + let link1 = Pubkey::new_unique(); + let link2 = Pubkey::new_unique(); + + mock.expect_check_requirements().returning(|_| Ok(())); + mock.expect_clear_topology() + .with(eq(ClearTopologyCommand { + name: "unicast-default".to_string(), + link_pubkeys: vec![link1, link2], + })) + .returning(|_| Ok(Signature::new_unique())); + + let cmd = ClearTopologyCliCommand { + name: "unicast-default".to_string(), + links: vec![link1.to_string(), link2.to_string()], + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + assert!(output.contains("Cleared topology 'unicast-default' from 2 link(s).")); + } + + #[test] + fn test_clear_topology_invalid_pubkey() { + let mut mock = MockCliCommand::new(); + + mock.expect_check_requirements().returning(|_| Ok(())); + + let cmd = ClearTopologyCliCommand { + name: "unicast-default".to_string(), + links: vec!["not-a-pubkey".to_string()], + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_err()); + } +} diff --git a/smartcontract/cli/src/topology/create.rs b/smartcontract/cli/src/topology/create.rs new file mode 100644 index 0000000000..b89b7af449 --- /dev/null +++ b/smartcontract/cli/src/topology/create.rs @@ -0,0 +1,100 @@ +use crate::{ + doublezerocommand::CliCommand, + requirements::{CHECK_BALANCE, CHECK_ID_JSON}, +}; +use clap::Args; +use doublezero_sdk::commands::topology::create::CreateTopologyCommand; +use doublezero_serviceability::state::topology::TopologyConstraint; +use std::io::Write; + +#[derive(Args, Debug)] +pub struct CreateTopologyCliCommand { + /// Name of the topology (max 32 bytes) + #[arg(long)] + pub name: String, + /// Constraint type: include-any or exclude + #[arg(long, value_parser = parse_constraint)] + pub constraint: TopologyConstraint, +} + +fn parse_constraint(s: &str) -> Result { + match s { + "include-any" => Ok(TopologyConstraint::IncludeAny), + "exclude" => Ok(TopologyConstraint::Exclude), + _ => Err(format!( + "invalid constraint '{}': expected 'include-any' or 'exclude'", + s + )), + } +} + +impl CreateTopologyCliCommand { + pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { + client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; + + let (_, topology_pda) = client.create_topology(CreateTopologyCommand { + name: self.name.clone(), + constraint: self.constraint, + })?; + writeln!( + out, + "Created topology '{}' successfully. PDA: {}", + self.name, topology_pda + )?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::doublezerocommand::MockCliCommand; + use doublezero_serviceability::state::topology::TopologyConstraint; + use mockall::predicate::eq; + use solana_sdk::{pubkey::Pubkey, signature::Signature}; + use std::io::Cursor; + + #[test] + fn test_create_topology_execute_success() { + let mut mock = MockCliCommand::new(); + let topology_pda = Pubkey::new_unique(); + + mock.expect_check_requirements().returning(|_| Ok(())); + mock.expect_create_topology() + .with(eq(CreateTopologyCommand { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + })) + .returning(move |_| Ok((Signature::new_unique(), topology_pda))); + + let cmd = CreateTopologyCliCommand { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + assert!(output.contains("Created topology 'unicast-default' successfully.")); + assert!(output.contains(&topology_pda.to_string())); + } + + #[test] + fn test_parse_constraint_include_any() { + assert_eq!( + parse_constraint("include-any"), + Ok(TopologyConstraint::IncludeAny) + ); + } + + #[test] + fn test_parse_constraint_exclude() { + assert_eq!(parse_constraint("exclude"), Ok(TopologyConstraint::Exclude)); + } + + #[test] + fn test_parse_constraint_invalid() { + assert!(parse_constraint("unknown").is_err()); + } +} diff --git a/smartcontract/cli/src/topology/delete.rs b/smartcontract/cli/src/topology/delete.rs new file mode 100644 index 0000000000..034404b325 --- /dev/null +++ b/smartcontract/cli/src/topology/delete.rs @@ -0,0 +1,57 @@ +use crate::{ + doublezerocommand::CliCommand, + requirements::{CHECK_BALANCE, CHECK_ID_JSON}, +}; +use clap::Args; +use doublezero_sdk::commands::topology::delete::DeleteTopologyCommand; +use std::io::Write; + +#[derive(Args, Debug)] +pub struct DeleteTopologyCliCommand { + /// Name of the topology to delete + #[arg(long)] + pub name: String, +} + +impl DeleteTopologyCliCommand { + pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { + client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; + + client.delete_topology(DeleteTopologyCommand { + name: self.name.clone(), + })?; + writeln!(out, "Deleted topology '{}' successfully.", self.name)?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::doublezerocommand::MockCliCommand; + use mockall::predicate::eq; + use solana_sdk::signature::Signature; + use std::io::Cursor; + + #[test] + fn test_delete_topology_execute_success() { + let mut mock = MockCliCommand::new(); + + mock.expect_check_requirements().returning(|_| Ok(())); + mock.expect_delete_topology() + .with(eq(DeleteTopologyCommand { + name: "unicast-default".to_string(), + })) + .returning(|_| Ok(Signature::new_unique())); + + let cmd = DeleteTopologyCliCommand { + name: "unicast-default".to_string(), + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + assert!(output.contains("Deleted topology 'unicast-default' successfully.")); + } +} diff --git a/smartcontract/cli/src/topology/list.rs b/smartcontract/cli/src/topology/list.rs new file mode 100644 index 0000000000..dbcfffd27c --- /dev/null +++ b/smartcontract/cli/src/topology/list.rs @@ -0,0 +1,99 @@ +use crate::doublezerocommand::CliCommand; +use clap::Args; +use doublezero_sdk::commands::topology::list::ListTopologyCommand; +use std::io::Write; + +#[derive(Args, Debug)] +pub struct ListTopologyCliCommand {} + +impl ListTopologyCliCommand { + pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { + let topologies = client.list_topology(ListTopologyCommand)?; + + if topologies.is_empty() { + writeln!(out, "No topologies found.")?; + return Ok(()); + } + + let mut entries: Vec<_> = topologies.into_values().collect(); + entries.sort_by_key(|t| t.admin_group_bit); + + writeln!( + out, + "{:<32} {:>3} {:>4} {:>5} {:?}", + "NAME", "BIT", "ALGO", "COLOR", "CONSTRAINT" + )?; + for t in &entries { + writeln!( + out, + "{:<32} {:>3} {:>4} {:>5} {:?}", + t.name, + t.admin_group_bit, + t.flex_algo_number, + t.admin_group_bit as u16 + 1, + t.constraint + )?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::doublezerocommand::MockCliCommand; + use doublezero_serviceability::state::{ + accounttype::AccountType, + topology::{TopologyConstraint, TopologyInfo}, + }; + use solana_sdk::pubkey::Pubkey; + use std::{collections::HashMap, io::Cursor}; + + #[test] + fn test_list_topology_empty() { + let mut mock = MockCliCommand::new(); + + mock.expect_list_topology() + .with(mockall::predicate::eq(ListTopologyCommand)) + .returning(|_| Ok(HashMap::new())); + + let cmd = ListTopologyCliCommand {}; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + assert!(output.contains("No topologies found.")); + } + + #[test] + fn test_list_topology_with_entries() { + let mut mock = MockCliCommand::new(); + + let topology = TopologyInfo { + account_type: AccountType::Topology, + owner: Pubkey::new_unique(), + bump_seed: 1, + name: "unicast-default".to_string(), + admin_group_bit: 0, + flex_algo_number: 128, + constraint: TopologyConstraint::IncludeAny, + }; + + mock.expect_list_topology() + .with(mockall::predicate::eq(ListTopologyCommand)) + .returning(move |_| { + let mut map = HashMap::new(); + map.insert(Pubkey::new_unique(), topology.clone()); + Ok(map) + }); + + let cmd = ListTopologyCliCommand {}; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + assert!(output.contains("unicast-default")); + assert!(output.contains("128")); + } +} diff --git a/smartcontract/cli/src/topology/mod.rs b/smartcontract/cli/src/topology/mod.rs new file mode 100644 index 0000000000..9c8c1e08a5 --- /dev/null +++ b/smartcontract/cli/src/topology/mod.rs @@ -0,0 +1,4 @@ +pub mod clear; +pub mod create; +pub mod delete; +pub mod list; diff --git a/smartcontract/sdk/rs/src/commands/link/activate.rs b/smartcontract/sdk/rs/src/commands/link/activate.rs index 628a5574ed..48ad41e7fa 100644 --- a/smartcontract/sdk/rs/src/commands/link/activate.rs +++ b/smartcontract/sdk/rs/src/commands/link/activate.rs @@ -4,8 +4,11 @@ use crate::{ }; use doublezero_program_common::types::NetworkV4; use doublezero_serviceability::{ - instructions::DoubleZeroInstruction, pda::get_resource_extension_pda, - processors::link::activate::LinkActivateArgs, resource::ResourceType, state::link::LinkStatus, + instructions::DoubleZeroInstruction, + pda::{get_resource_extension_pda, get_topology_pda}, + processors::link::activate::LinkActivateArgs, + resource::ResourceType, + state::link::LinkStatus, }; use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; @@ -61,6 +64,11 @@ impl ActivateLinkCommand { accounts.push(AccountMeta::new(link_ids_ext, false)); } + // Always include the unicast-default topology account (required by the on-chain program) + let (unicast_default_pda, _) = + get_topology_pda(&client.get_program_id(), "unicast-default"); + accounts.push(AccountMeta::new_readonly(unicast_default_pda, false)); + client.execute_transaction( DoubleZeroInstruction::ActivateLink(LinkActivateArgs { tunnel_id: self.tunnel_id, @@ -81,7 +89,7 @@ mod tests { use doublezero_program_common::types::NetworkV4; use doublezero_serviceability::{ instructions::DoubleZeroInstruction, - pda::{get_globalstate_pda, get_resource_extension_pda}, + pda::{get_globalstate_pda, get_resource_extension_pda, get_topology_pda}, processors::link::activate::LinkActivateArgs, resource::ResourceType, state::{ @@ -98,6 +106,8 @@ mod tests { let mut client = create_test_client(); let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (unicast_default_pda, _) = + get_topology_pda(&client.get_program_id(), "unicast-default"); let link_pubkey = Pubkey::new_unique(); let side_a_pk = Pubkey::new_unique(); let side_z_pk = Pubkey::new_unique(); @@ -150,6 +160,7 @@ mod tests { AccountMeta::new(side_a_pk, false), AccountMeta::new(side_z_pk, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ]), ) .returning(|_, _| Ok(Signature::new_unique())); @@ -172,6 +183,8 @@ mod tests { let mut client = create_test_client(); let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (unicast_default_pda, _) = + get_topology_pda(&client.get_program_id(), "unicast-default"); let link_pubkey = Pubkey::new_unique(); let side_a_pk = Pubkey::new_unique(); let side_z_pk = Pubkey::new_unique(); @@ -230,6 +243,7 @@ mod tests { AccountMeta::new(globalstate_pubkey, false), AccountMeta::new(device_tunnel_block_ext, false), AccountMeta::new(link_ids_ext, false), + AccountMeta::new_readonly(unicast_default_pda, false), ]), ) .returning(|_, _| Ok(Signature::new_unique())); diff --git a/smartcontract/sdk/rs/src/commands/mod.rs b/smartcontract/sdk/rs/src/commands/mod.rs index ed36fffae3..f1bae67451 100644 --- a/smartcontract/sdk/rs/src/commands/mod.rs +++ b/smartcontract/sdk/rs/src/commands/mod.rs @@ -13,4 +13,5 @@ pub mod permission; pub mod programconfig; pub mod resource; pub mod tenant; +pub mod topology; pub mod user; diff --git a/smartcontract/sdk/rs/src/commands/topology/clear.rs b/smartcontract/sdk/rs/src/commands/topology/clear.rs new file mode 100644 index 0000000000..6e3c48b971 --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/topology/clear.rs @@ -0,0 +1,115 @@ +use crate::{commands::globalstate::get::GetGlobalStateCommand, DoubleZeroClient}; +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, pda::get_topology_pda, + processors::topology::clear::TopologyClearArgs, +}; +use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + +#[derive(Debug, PartialEq, Clone)] +pub struct ClearTopologyCommand { + pub name: String, + pub link_pubkeys: Vec, +} + +impl ClearTopologyCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result { + let (globalstate_pubkey, _globalstate) = GetGlobalStateCommand + .execute(client) + .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; + + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), &self.name); + + let mut accounts = vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]; + + for link_pk in &self.link_pubkeys { + accounts.push(AccountMeta::new(*link_pk, false)); + } + + client.execute_transaction( + DoubleZeroInstruction::ClearTopology(TopologyClearArgs { + name: self.name.clone(), + }), + accounts, + ) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + commands::topology::clear::ClearTopologyCommand, tests::utils::create_test_client, + DoubleZeroClient, + }; + use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_globalstate_pda, get_topology_pda}, + processors::topology::clear::TopologyClearArgs, + }; + use mockall::predicate; + use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + + #[test] + fn test_commands_topology_clear_command_no_links() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "my-topology"); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::ClearTopology(TopologyClearArgs { + name: "my-topology".to_string(), + })), + predicate::eq(vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = ClearTopologyCommand { + name: "my-topology".to_string(), + link_pubkeys: vec![], + } + .execute(&client); + + assert!(res.is_ok()); + } + + #[test] + fn test_commands_topology_clear_command_with_links() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "my-topology"); + let link1 = Pubkey::new_unique(); + let link2 = Pubkey::new_unique(); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::ClearTopology(TopologyClearArgs { + name: "my-topology".to_string(), + })), + predicate::eq(vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(link1, false), + AccountMeta::new(link2, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = ClearTopologyCommand { + name: "my-topology".to_string(), + link_pubkeys: vec![link1, link2], + } + .execute(&client); + + assert!(res.is_ok()); + } +} diff --git a/smartcontract/sdk/rs/src/commands/topology/create.rs b/smartcontract/sdk/rs/src/commands/topology/create.rs new file mode 100644 index 0000000000..c8efc3668e --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/topology/create.rs @@ -0,0 +1,93 @@ +use crate::{commands::globalstate::get::GetGlobalStateCommand, DoubleZeroClient}; +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_resource_extension_pda, get_topology_pda}, + processors::topology::create::TopologyCreateArgs, + resource::ResourceType, + state::topology::TopologyConstraint, +}; +use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + +#[derive(Debug, PartialEq, Clone)] +pub struct CreateTopologyCommand { + pub name: String, + pub constraint: TopologyConstraint, +} + +impl CreateTopologyCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result<(Signature, Pubkey)> { + let (globalstate_pubkey, _globalstate) = GetGlobalStateCommand + .execute(client) + .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; + + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), &self.name); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&client.get_program_id(), ResourceType::AdminGroupBits); + + client + .execute_transaction( + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: self.name.clone(), + constraint: self.constraint, + }), + vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + ) + .map(|sig| (sig, topology_pda)) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + commands::topology::create::CreateTopologyCommand, tests::utils::create_test_client, + DoubleZeroClient, + }; + use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_globalstate_pda, get_resource_extension_pda, get_topology_pda}, + processors::topology::create::TopologyCreateArgs, + resource::ResourceType, + state::topology::TopologyConstraint, + }; + use mockall::predicate; + use solana_sdk::{instruction::AccountMeta, signature::Signature}; + + #[test] + fn test_commands_topology_create_command() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "unicast-default"); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&client.get_program_id(), ResourceType::AdminGroupBits); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + })), + predicate::eq(vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = CreateTopologyCommand { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + } + .execute(&client); + + assert!(res.is_ok()); + let (_, pda) = res.unwrap(); + assert_eq!(pda, topology_pda); + } +} diff --git a/smartcontract/sdk/rs/src/commands/topology/delete.rs b/smartcontract/sdk/rs/src/commands/topology/delete.rs new file mode 100644 index 0000000000..df5d0ec6e7 --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/topology/delete.rs @@ -0,0 +1,74 @@ +use crate::{commands::globalstate::get::GetGlobalStateCommand, DoubleZeroClient}; +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, pda::get_topology_pda, + processors::topology::delete::TopologyDeleteArgs, +}; +use solana_sdk::{instruction::AccountMeta, signature::Signature}; + +#[derive(Debug, PartialEq, Clone)] +pub struct DeleteTopologyCommand { + pub name: String, +} + +impl DeleteTopologyCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result { + let (globalstate_pubkey, _globalstate) = GetGlobalStateCommand + .execute(client) + .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; + + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), &self.name); + + client.execute_transaction( + DoubleZeroInstruction::DeleteTopology(TopologyDeleteArgs { + name: self.name.clone(), + }), + vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + ) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + commands::topology::delete::DeleteTopologyCommand, tests::utils::create_test_client, + DoubleZeroClient, + }; + use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_globalstate_pda, get_topology_pda}, + processors::topology::delete::TopologyDeleteArgs, + }; + use mockall::predicate; + use solana_sdk::{instruction::AccountMeta, signature::Signature}; + + #[test] + fn test_commands_topology_delete_command() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "unicast-default"); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::DeleteTopology(TopologyDeleteArgs { + name: "unicast-default".to_string(), + })), + predicate::eq(vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = DeleteTopologyCommand { + name: "unicast-default".to_string(), + } + .execute(&client); + + assert!(res.is_ok()); + } +} diff --git a/smartcontract/sdk/rs/src/commands/topology/list.rs b/smartcontract/sdk/rs/src/commands/topology/list.rs new file mode 100644 index 0000000000..e19956b09c --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/topology/list.rs @@ -0,0 +1,85 @@ +use crate::DoubleZeroClient; +use doublezero_serviceability::{ + error::DoubleZeroError, + state::{accountdata::AccountData, accounttype::AccountType, topology::TopologyInfo}, +}; +use solana_sdk::pubkey::Pubkey; +use std::collections::HashMap; + +#[derive(Debug, PartialEq, Clone)] +pub struct ListTopologyCommand; + +impl ListTopologyCommand { + pub fn execute( + &self, + client: &dyn DoubleZeroClient, + ) -> eyre::Result> { + client + .gets(AccountType::Topology)? + .into_iter() + .map(|(k, v)| match v { + AccountData::Topology(topology) => Ok((k, topology)), + _ => Err(DoubleZeroError::InvalidAccountType.into()), + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use crate::{commands::topology::list::ListTopologyCommand, tests::utils::create_test_client}; + use doublezero_serviceability::state::{ + accountdata::AccountData, + accounttype::AccountType, + topology::{TopologyConstraint, TopologyInfo}, + }; + use mockall::predicate; + use solana_sdk::pubkey::Pubkey; + + #[test] + fn test_commands_topology_list_command() { + let mut client = create_test_client(); + + let topology1_pubkey = Pubkey::new_unique(); + let topology1 = TopologyInfo { + account_type: AccountType::Topology, + owner: Pubkey::new_unique(), + bump_seed: 1, + name: "unicast-default".to_string(), + admin_group_bit: 0, + flex_algo_number: 128, + constraint: TopologyConstraint::IncludeAny, + }; + + let topology2_pubkey = Pubkey::new_unique(); + let topology2 = TopologyInfo { + account_type: AccountType::Topology, + owner: Pubkey::new_unique(), + bump_seed: 2, + name: "exclude-test".to_string(), + admin_group_bit: 2, + flex_algo_number: 130, + constraint: TopologyConstraint::Exclude, + }; + + client + .expect_gets() + .with(predicate::eq(AccountType::Topology)) + .returning(move |_| { + let mut topologies = HashMap::new(); + topologies.insert(topology1_pubkey, AccountData::Topology(topology1.clone())); + topologies.insert(topology2_pubkey, AccountData::Topology(topology2.clone())); + Ok(topologies) + }); + + let res = ListTopologyCommand.execute(&client); + + assert!(res.is_ok()); + let list = res.unwrap(); + assert_eq!(list.len(), 2); + assert!(list.contains_key(&topology1_pubkey)); + assert!(list.contains_key(&topology2_pubkey)); + } +} diff --git a/smartcontract/sdk/rs/src/commands/topology/mod.rs b/smartcontract/sdk/rs/src/commands/topology/mod.rs new file mode 100644 index 0000000000..9c8c1e08a5 --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/topology/mod.rs @@ -0,0 +1,4 @@ +pub mod clear; +pub mod create; +pub mod delete; +pub mod list; diff --git a/smartcontract/sdk/rs/src/lib.rs b/smartcontract/sdk/rs/src/lib.rs index 682eeeaf46..f1ccd758cf 100644 --- a/smartcontract/sdk/rs/src/lib.rs +++ b/smartcontract/sdk/rs/src/lib.rs @@ -8,7 +8,7 @@ pub use doublezero_serviceability::{ pda::{ get_contributor_pda, get_device_pda, get_exchange_pda, get_globalconfig_pda, get_link_pda, get_location_pda, get_multicastgroup_pda, get_permission_pda, get_resource_extension_pda, - get_tenant_pda, get_user_old_pda, + get_tenant_pda, get_topology_pda, get_user_old_pda, }, programversion::ProgramVersion, resource::{IdOrIp, ResourceType}, @@ -30,6 +30,7 @@ pub use doublezero_serviceability::{ programconfig::ProgramConfig, resource_extension::ResourceExtensionOwned, tenant::Tenant, + topology::{TopologyConstraint, TopologyInfo}, user::{User, UserCYOA, UserStatus, UserType}, }, }; From 20eab2cd23643015dc1bab49568d44f50f11c806 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 10:08:04 -0500 Subject: [PATCH 19/49] cli: topology delete guard referencing links; list shows link counts and --json --- smartcontract/cli/src/topology/delete.rs | 92 +++++++++- smartcontract/cli/src/topology/list.rs | 204 ++++++++++++++++++++--- 2 files changed, 272 insertions(+), 24 deletions(-) diff --git a/smartcontract/cli/src/topology/delete.rs b/smartcontract/cli/src/topology/delete.rs index 034404b325..487f4d23f0 100644 --- a/smartcontract/cli/src/topology/delete.rs +++ b/smartcontract/cli/src/topology/delete.rs @@ -3,7 +3,10 @@ use crate::{ requirements::{CHECK_BALANCE, CHECK_ID_JSON}, }; use clap::Args; -use doublezero_sdk::commands::topology::delete::DeleteTopologyCommand; +use doublezero_sdk::{ + commands::{link::list::ListLinkCommand, topology::delete::DeleteTopologyCommand}, + get_topology_pda, +}; use std::io::Write; #[derive(Args, Debug)] @@ -17,6 +20,23 @@ impl DeleteTopologyCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; + // Guard: check if any links still reference this topology + let program_id = client.get_program_id(); + let topology_pda = get_topology_pda(&program_id, &self.name).0; + let links = client.list_link(ListLinkCommand)?; + let referencing_count = links + .values() + .filter(|link| link.link_topologies.contains(&topology_pda)) + .count(); + if referencing_count > 0 { + return Err(eyre::eyre!( + "Cannot delete topology '{}': {} link(s) still reference it. Run 'doublezero link topology clear --name {}' first.", + self.name, + referencing_count, + self.name, + )); + } + client.delete_topology(DeleteTopologyCommand { name: self.name.clone(), })?; @@ -29,16 +49,27 @@ impl DeleteTopologyCliCommand { #[cfg(test)] mod tests { use super::*; - use crate::doublezerocommand::MockCliCommand; + use crate::{doublezerocommand::MockCliCommand, tests::utils::create_test_client}; + use doublezero_sdk::{ + commands::topology::delete::DeleteTopologyCommand, get_topology_pda, Link, LinkLinkType, + LinkStatus, + }; + use doublezero_serviceability::state::{ + accounttype::AccountType, + link::{LinkDesiredStatus, LinkHealth}, + }; use mockall::predicate::eq; - use solana_sdk::signature::Signature; - use std::io::Cursor; + use solana_sdk::{pubkey::Pubkey, signature::Signature}; + use std::{collections::HashMap, io::Cursor}; #[test] fn test_delete_topology_execute_success() { let mut mock = MockCliCommand::new(); mock.expect_check_requirements().returning(|_| Ok(())); + mock.expect_get_program_id() + .returning(|| Pubkey::from_str_const("GYhQDKuESrasNZGyhMJhGYFtbzNijYhcrN9poSqCQVah")); + mock.expect_list_link().returning(|_| Ok(HashMap::new())); mock.expect_delete_topology() .with(eq(DeleteTopologyCommand { name: "unicast-default".to_string(), @@ -54,4 +85,57 @@ mod tests { let output = String::from_utf8(out.into_inner()).unwrap(); assert!(output.contains("Deleted topology 'unicast-default' successfully.")); } + + #[test] + fn test_delete_topology_blocked_by_referencing_links() { + let mut client = create_test_client(); + + client.expect_check_requirements().returning(|_| Ok(())); + + let program_id = Pubkey::from_str_const("GYhQDKuESrasNZGyhMJhGYFtbzNijYhcrN9poSqCQVah"); + let topology_pda = get_topology_pda(&program_id, "unicast-default").0; + + let link = Link { + account_type: AccountType::Link, + index: 1, + bump_seed: 2, + code: "link1".to_string(), + contributor_pk: Pubkey::new_unique(), + side_a_pk: Pubkey::new_unique(), + side_z_pk: Pubkey::new_unique(), + link_type: LinkLinkType::WAN, + bandwidth: 10_000_000_000, + mtu: 4500, + delay_ns: 0, + jitter_ns: 0, + delay_override_ns: 0, + tunnel_id: 1, + tunnel_net: "10.0.0.0/30".parse().unwrap(), + status: LinkStatus::Activated, + owner: Pubkey::new_unique(), + side_a_iface_name: "eth0".to_string(), + side_z_iface_name: "eth1".to_string(), + link_health: LinkHealth::ReadyForService, + desired_status: LinkDesiredStatus::Activated, + link_topologies: vec![topology_pda], + unicast_drained: false, + }; + + client.expect_list_link().returning(move |_| { + let mut links = HashMap::new(); + links.insert(Pubkey::new_unique(), link.clone()); + Ok(links) + }); + + let cmd = DeleteTopologyCliCommand { + name: "unicast-default".to_string(), + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&client, &mut out); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Cannot delete topology 'unicast-default'")); + assert!(err.contains("1 link(s) still reference it")); + assert!(err.contains("doublezero link topology clear --name unicast-default")); + } } diff --git a/smartcontract/cli/src/topology/list.rs b/smartcontract/cli/src/topology/list.rs index dbcfffd27c..9f67f462a3 100644 --- a/smartcontract/cli/src/topology/list.rs +++ b/smartcontract/cli/src/topology/list.rs @@ -1,10 +1,28 @@ use crate::doublezerocommand::CliCommand; use clap::Args; -use doublezero_sdk::commands::topology::list::ListTopologyCommand; +use doublezero_sdk::{ + commands::{link::list::ListLinkCommand, topology::list::ListTopologyCommand}, + get_topology_pda, +}; +use serde::Serialize; use std::io::Write; #[derive(Args, Debug)] -pub struct ListTopologyCliCommand {} +pub struct ListTopologyCliCommand { + /// Output as pretty JSON. + #[arg(long, default_value_t = false)] + pub json: bool, +} + +#[derive(Serialize)] +pub struct TopologyDisplay { + pub name: String, + pub bit: u8, + pub algo: u8, + pub color: u16, + pub constraint: String, + pub links: usize, +} impl ListTopologyCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { @@ -15,23 +33,49 @@ impl ListTopologyCliCommand { return Ok(()); } - let mut entries: Vec<_> = topologies.into_values().collect(); - entries.sort_by_key(|t| t.admin_group_bit); + let links = client.list_link(ListLinkCommand)?; + let program_id = client.get_program_id(); + + let mut entries: Vec<_> = topologies.into_iter().collect(); + entries.sort_by_key(|(_, t)| t.admin_group_bit); + + let displays: Vec = entries + .iter() + .map(|(pda, t)| { + // Count how many links reference this topology PDA. The topology_pda + // is derived from name, but we already have the PDA as the map key. + let _ = get_topology_pda(&program_id, &t.name); // kept for symmetry; use key directly + let link_count = links + .values() + .filter(|link| link.link_topologies.contains(pda)) + .count(); + TopologyDisplay { + name: t.name.clone(), + bit: t.admin_group_bit, + algo: t.flex_algo_number, + color: t.admin_group_bit as u16 + 1, + constraint: format!("{:?}", t.constraint), + links: link_count, + } + }) + .collect(); + + if self.json { + serde_json::to_writer_pretty(&mut *out, &displays)?; + writeln!(out)?; + return Ok(()); + } writeln!( out, - "{:<32} {:>3} {:>4} {:>5} {:?}", - "NAME", "BIT", "ALGO", "COLOR", "CONSTRAINT" + "{:<32} {:>3} {:>4} {:>5} {:>5} {:?}", + "NAME", "BIT", "ALGO", "COLOR", "LINKS", "CONSTRAINT" )?; - for t in &entries { + for d in &displays { writeln!( out, - "{:<32} {:>3} {:>4} {:>5} {:?}", - t.name, - t.admin_group_bit, - t.flex_algo_number, - t.admin_group_bit as u16 + 1, - t.constraint + "{:<32} {:>3} {:>4} {:>5} {:>5} {}", + d.name, d.bit, d.algo, d.color, d.links, d.constraint, )?; } @@ -42,9 +86,11 @@ impl ListTopologyCliCommand { #[cfg(test)] mod tests { use super::*; - use crate::doublezerocommand::MockCliCommand; + use crate::{doublezerocommand::MockCliCommand, tests::utils::create_test_client}; + use doublezero_sdk::{get_topology_pda, Link, LinkLinkType, LinkStatus}; use doublezero_serviceability::state::{ accounttype::AccountType, + link::{LinkDesiredStatus, LinkHealth}, topology::{TopologyConstraint, TopologyInfo}, }; use solana_sdk::pubkey::Pubkey; @@ -58,7 +104,7 @@ mod tests { .with(mockall::predicate::eq(ListTopologyCommand)) .returning(|_| Ok(HashMap::new())); - let cmd = ListTopologyCliCommand {}; + let cmd = ListTopologyCliCommand { json: false }; let mut out = Cursor::new(Vec::new()); let result = cmd.execute(&mock, &mut out); assert!(result.is_ok()); @@ -68,7 +114,10 @@ mod tests { #[test] fn test_list_topology_with_entries() { - let mut mock = MockCliCommand::new(); + let mut client = create_test_client(); + + let program_id = Pubkey::from_str_const("GYhQDKuESrasNZGyhMJhGYFtbzNijYhcrN9poSqCQVah"); + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); let topology = TopologyInfo { account_type: AccountType::Topology, @@ -80,20 +129,135 @@ mod tests { constraint: TopologyConstraint::IncludeAny, }; - mock.expect_list_topology() + client + .expect_list_topology() .with(mockall::predicate::eq(ListTopologyCommand)) .returning(move |_| { let mut map = HashMap::new(); - map.insert(Pubkey::new_unique(), topology.clone()); + map.insert(topology_pda, topology.clone()); Ok(map) }); - let cmd = ListTopologyCliCommand {}; + client.expect_list_link().returning(|_| Ok(HashMap::new())); + + let cmd = ListTopologyCliCommand { json: false }; let mut out = Cursor::new(Vec::new()); - let result = cmd.execute(&mock, &mut out); + let result = cmd.execute(&client, &mut out); assert!(result.is_ok()); let output = String::from_utf8(out.into_inner()).unwrap(); assert!(output.contains("unicast-default")); assert!(output.contains("128")); + assert!(output.contains("LINKS")); + } + + #[test] + fn test_list_topology_link_count() { + let mut client = create_test_client(); + + let program_id = Pubkey::from_str_const("GYhQDKuESrasNZGyhMJhGYFtbzNijYhcrN9poSqCQVah"); + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + let topology = TopologyInfo { + account_type: AccountType::Topology, + owner: Pubkey::new_unique(), + bump_seed: 1, + name: "unicast-default".to_string(), + admin_group_bit: 0, + flex_algo_number: 128, + constraint: TopologyConstraint::IncludeAny, + }; + + client + .expect_list_topology() + .with(mockall::predicate::eq(ListTopologyCommand)) + .returning(move |_| { + let mut map = HashMap::new(); + map.insert(topology_pda, topology.clone()); + Ok(map) + }); + + let link = Link { + account_type: AccountType::Link, + index: 1, + bump_seed: 2, + code: "link1".to_string(), + contributor_pk: Pubkey::new_unique(), + side_a_pk: Pubkey::new_unique(), + side_z_pk: Pubkey::new_unique(), + link_type: LinkLinkType::WAN, + bandwidth: 10_000_000_000, + mtu: 4500, + delay_ns: 0, + jitter_ns: 0, + delay_override_ns: 0, + tunnel_id: 1, + tunnel_net: "10.0.0.0/30".parse().unwrap(), + status: LinkStatus::Activated, + owner: Pubkey::new_unique(), + side_a_iface_name: "eth0".to_string(), + side_z_iface_name: "eth1".to_string(), + link_health: LinkHealth::ReadyForService, + desired_status: LinkDesiredStatus::Activated, + link_topologies: vec![topology_pda], + unicast_drained: false, + }; + + client.expect_list_link().returning(move |_| { + let mut links = HashMap::new(); + links.insert(Pubkey::new_unique(), link.clone()); + Ok(links) + }); + + let cmd = ListTopologyCliCommand { json: false }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&client, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + // The link count should be 1 + assert!( + output.contains(" 1"), + "expected link count 1 in output: {output}" + ); + } + + #[test] + fn test_list_topology_json_output() { + let mut client = create_test_client(); + + let program_id = Pubkey::from_str_const("GYhQDKuESrasNZGyhMJhGYFtbzNijYhcrN9poSqCQVah"); + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + let topology = TopologyInfo { + account_type: AccountType::Topology, + owner: Pubkey::new_unique(), + bump_seed: 1, + name: "unicast-default".to_string(), + admin_group_bit: 0, + flex_algo_number: 128, + constraint: TopologyConstraint::IncludeAny, + }; + + client + .expect_list_topology() + .with(mockall::predicate::eq(ListTopologyCommand)) + .returning(move |_| { + let mut map = HashMap::new(); + map.insert(topology_pda, topology.clone()); + Ok(map) + }); + + client.expect_list_link().returning(|_| Ok(HashMap::new())); + + let cmd = ListTopologyCliCommand { json: true }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&client, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).expect("valid JSON"); + assert!(parsed.is_array()); + let arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["name"], "unicast-default"); + assert_eq!(arr[0]["links"], 0); } } From c5663869731c7af3d031552eb70c6b4b9d79e18d Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 10:26:56 -0500 Subject: [PATCH 20/49] cli: add --include-topologies to doublezero tenant update --- smartcontract/cli/src/tenant/get.rs | 7 ++++ smartcontract/cli/src/tenant/list.rs | 11 +++++-- smartcontract/cli/src/tenant/update.rs | 33 ++++++++++++++++++- .../sdk/rs/src/commands/tenant/update.rs | 3 +- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/smartcontract/cli/src/tenant/get.rs b/smartcontract/cli/src/tenant/get.rs index 0b1e82acb7..4b6353353a 100644 --- a/smartcontract/cli/src/tenant/get.rs +++ b/smartcontract/cli/src/tenant/get.rs @@ -30,6 +30,7 @@ struct TenantDisplay { pub administrators: String, pub token_account: String, pub reference_count: u32, + pub include_topologies: String, #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] pub owner: Pubkey, } @@ -56,6 +57,12 @@ impl GetTenantCliCommand { .join(", "), token_account: tenant.token_account.to_string(), reference_count: tenant.reference_count, + include_topologies: tenant + .include_topologies + .iter() + .map(|pk| pk.to_string()) + .collect::>() + .join(", "), owner: tenant.owner, }; diff --git a/smartcontract/cli/src/tenant/list.rs b/smartcontract/cli/src/tenant/list.rs index 20d06a8e46..6740eeee5d 100644 --- a/smartcontract/cli/src/tenant/list.rs +++ b/smartcontract/cli/src/tenant/list.rs @@ -25,6 +25,7 @@ pub struct TenantDisplay { pub vrf_id: u16, pub metro_routing: bool, pub route_liveness: bool, + pub include_topologies: String, #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] pub owner: Pubkey, } @@ -41,6 +42,12 @@ impl ListTenantCliCommand { vrf_id: tenant.vrf_id, metro_routing: tenant.metro_routing, route_liveness: tenant.route_liveness, + include_topologies: tenant + .include_topologies + .iter() + .map(|pk| pk.to_string()) + .collect::>() + .join(", "), owner: tenant.owner, }) .collect(); @@ -109,7 +116,7 @@ mod tests { let output_str = String::from_utf8(output).unwrap(); assert_eq!( output_str, - " account | code | vrf_id | metro_routing | route_liveness | owner \n 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo | tenant-a | 100 | true | false | 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo \n" + " account | code | vrf_id | metro_routing | route_liveness | include_topologies | owner \n 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo | tenant-a | 100 | true | false | | 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo \n" ); let mut output = Vec::new(); @@ -122,7 +129,7 @@ mod tests { let output_str = String::from_utf8(output).unwrap(); assert_eq!( output_str, - "[{\"account\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\",\"code\":\"tenant-a\",\"vrf_id\":100,\"metro_routing\":true,\"route_liveness\":false,\"owner\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\"}]\n" + "[{\"account\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\",\"code\":\"tenant-a\",\"vrf_id\":100,\"metro_routing\":true,\"route_liveness\":false,\"include_topologies\":\"\",\"owner\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\"}]\n" ); } } diff --git a/smartcontract/cli/src/tenant/update.rs b/smartcontract/cli/src/tenant/update.rs index fbd8cfc83b..acaeb05bd7 100644 --- a/smartcontract/cli/src/tenant/update.rs +++ b/smartcontract/cli/src/tenant/update.rs @@ -4,7 +4,10 @@ use crate::{ validators::validate_pubkey_or_code, }; use clap::Args; -use doublezero_sdk::commands::tenant::{get::GetTenantCommand, update::UpdateTenantCommand}; +use doublezero_sdk::{ + commands::tenant::{get::GetTenantCommand, update::UpdateTenantCommand}, + get_topology_pda, +}; use doublezero_serviceability::state::tenant::{FlatPerEpochConfig, TenantBillingConfig}; use solana_sdk::pubkey::Pubkey; use std::{io::Write, str::FromStr}; @@ -29,6 +32,9 @@ pub struct UpdateTenantCliCommand { /// Flat billing rate per epoch (in lamports) #[arg(long)] pub billing_rate: Option, + /// Comma-separated topology names to assign to this tenant (foundation-only). Use "default" to clear. + #[arg(long)] + pub include_topologies: Option, } impl UpdateTenantCliCommand { @@ -54,6 +60,28 @@ impl UpdateTenantCliCommand { }) }); + let include_topologies = if let Some(ref topo_arg) = self.include_topologies { + if topo_arg == "default" { + Some(vec![]) + } else { + let program_id = client.get_program_id(); + let pubkeys: eyre::Result> = topo_arg + .split(',') + .map(|name| { + let name = name.trim(); + let pda = get_topology_pda(&program_id, name).0; + client + .get_account(pda) + .map_err(|_| eyre::eyre!("Topology '{}' not found", name))?; + Ok(pda) + }) + .collect(); + Some(pubkeys?) + } + } else { + None + }; + let signature = client.update_tenant(UpdateTenantCommand { tenant_pubkey, vrf_id: self.vrf_id, @@ -61,6 +89,7 @@ impl UpdateTenantCliCommand { metro_routing: self.metro_routing, route_liveness: self.route_liveness, billing, + include_topologies, })?; writeln!(out, "Signature: {signature}")?; @@ -133,6 +162,7 @@ mod tests { metro_routing: Some(true), route_liveness: None, billing: None, + include_topologies: None, })) .returning(move |_| Ok(signature)); @@ -145,6 +175,7 @@ mod tests { metro_routing: Some(true), route_liveness: None, billing_rate: None, + include_topologies: None, } .execute(&client, &mut output); assert!(res.is_ok()); diff --git a/smartcontract/sdk/rs/src/commands/tenant/update.rs b/smartcontract/sdk/rs/src/commands/tenant/update.rs index b0b511e37d..97a7eb109d 100644 --- a/smartcontract/sdk/rs/src/commands/tenant/update.rs +++ b/smartcontract/sdk/rs/src/commands/tenant/update.rs @@ -13,6 +13,7 @@ pub struct UpdateTenantCommand { pub metro_routing: Option, pub route_liveness: Option, pub billing: Option, + pub include_topologies: Option>, } impl UpdateTenantCommand { @@ -26,7 +27,7 @@ impl UpdateTenantCommand { metro_routing: self.metro_routing, route_liveness: self.route_liveness, billing: self.billing, - include_topologies: None, + include_topologies: self.include_topologies.clone(), }), vec![ AccountMeta::new(self.tenant_pubkey, false), From f49389bd588c02b753208bd7a08f128ff45e4f5a Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 10:39:40 -0500 Subject: [PATCH 21/49] cli: add --link-topology and --unicast-drained to doublezero link update --- smartcontract/cli/src/link/get.rs | 9 ++++++ smartcontract/cli/src/link/list.rs | 13 +++++++-- smartcontract/cli/src/link/update.rs | 29 +++++++++++++++++++ .../sdk/rs/src/commands/link/update.rs | 6 ++-- 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/smartcontract/cli/src/link/get.rs b/smartcontract/cli/src/link/get.rs index 14bc462363..8236771372 100644 --- a/smartcontract/cli/src/link/get.rs +++ b/smartcontract/cli/src/link/get.rs @@ -47,6 +47,8 @@ struct LinkDisplay { pub status: String, pub health: String, pub owner: String, + pub link_topologies: String, + pub unicast_drained: bool, } impl GetLinkCliCommand { @@ -92,6 +94,13 @@ impl GetLinkCliCommand { status: link.status.to_string(), health: link.link_health.to_string(), owner: link.owner.to_string(), + link_topologies: link + .link_topologies + .iter() + .map(|pk| pk.to_string()) + .collect::>() + .join(", "), + unicast_drained: link.unicast_drained, }; if self.json { diff --git a/smartcontract/cli/src/link/list.rs b/smartcontract/cli/src/link/list.rs index 83d015d40f..d2f8f0b81b 100644 --- a/smartcontract/cli/src/link/list.rs +++ b/smartcontract/cli/src/link/list.rs @@ -92,6 +92,8 @@ pub struct LinkDisplay { pub health: LinkHealth, #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] pub owner: Pubkey, + pub link_topologies: String, + pub unicast_drained: bool, } impl ListLinkCliCommand { @@ -217,6 +219,13 @@ impl ListLinkCliCommand { status: link.status, health: link.link_health, owner: link.owner, + link_topologies: link + .link_topologies + .iter() + .map(|pk| pk.to_string()) + .collect::>() + .join(", "), + unicast_drained: link.unicast_drained, } }) .collect(); @@ -407,7 +416,7 @@ mod tests { assert!(res.is_ok()); let output_str = String::from_utf8(output).unwrap(); - assert_eq!(output_str, " account | code | contributor | side_a_name | side_a_iface_name | side_z_name | side_z_iface_name | link_type | bandwidth | mtu | delay_ms | jitter_ms | delay_override_ms | tunnel_id | tunnel_net | status | health | owner \n 1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR | tunnel_code | contributor1_code | device2_code | eth0 | device2_code | eth1 | WAN | 10Gbps | 4500 | 0.02ms | 0.00ms | 0.00ms | 1234 | 1.2.3.4/32 | activated | ready-for-service | 11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9 \n"); + assert_eq!(output_str, " account | code | contributor | side_a_name | side_a_iface_name | side_z_name | side_z_iface_name | link_type | bandwidth | mtu | delay_ms | jitter_ms | delay_override_ms | tunnel_id | tunnel_net | status | health | owner | link_topologies | unicast_drained \n 1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR | tunnel_code | contributor1_code | device2_code | eth0 | device2_code | eth1 | WAN | 10Gbps | 4500 | 0.02ms | 0.00ms | 0.00ms | 1234 | 1.2.3.4/32 | activated | ready-for-service | 11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9 | | false \n"); let mut output = Vec::new(); let res = ListLinkCliCommand { @@ -428,7 +437,7 @@ mod tests { assert!(res.is_ok()); let output_str = String::from_utf8(output).unwrap(); - assert_eq!(output_str, "[{\"account\":\"1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR\",\"code\":\"tunnel_code\",\"contributor_code\":\"contributor1_code\",\"side_a_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_a_name\":\"device2_code\",\"side_a_iface_name\":\"eth0\",\"side_z_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_z_name\":\"device2_code\",\"side_z_iface_name\":\"eth1\",\"link_type\":\"WAN\",\"bandwidth\":10000000000,\"mtu\":4500,\"delay_ns\":20000,\"jitter_ns\":1121,\"delay_override_ns\":0,\"tunnel_id\":1234,\"tunnel_net\":\"1.2.3.4/32\",\"desired_status\":\"Activated\",\"status\":\"Activated\",\"health\":\"ReadyForService\",\"owner\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\"}]\n"); + assert_eq!(output_str, "[{\"account\":\"1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR\",\"code\":\"tunnel_code\",\"contributor_code\":\"contributor1_code\",\"side_a_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_a_name\":\"device2_code\",\"side_a_iface_name\":\"eth0\",\"side_z_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_z_name\":\"device2_code\",\"side_z_iface_name\":\"eth1\",\"link_type\":\"WAN\",\"bandwidth\":10000000000,\"mtu\":4500,\"delay_ns\":20000,\"jitter_ns\":1121,\"delay_override_ns\":0,\"tunnel_id\":1234,\"tunnel_net\":\"1.2.3.4/32\",\"desired_status\":\"Activated\",\"status\":\"Activated\",\"health\":\"ReadyForService\",\"owner\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"link_topologies\":\"\",\"unicast_drained\":false}]\n"); } #[test] diff --git a/smartcontract/cli/src/link/update.rs b/smartcontract/cli/src/link/update.rs index caefd8b921..2517c2ef81 100644 --- a/smartcontract/cli/src/link/update.rs +++ b/smartcontract/cli/src/link/update.rs @@ -59,6 +59,12 @@ pub struct UpdateLinkCliCommand { /// Reassign tunnel network (foundation-only, e.g. 172.16.1.100/31) #[arg(long)] pub tunnel_net: Option, + /// Topology name to tag this link with (foundation-only). Use "default" to clear. + #[arg(long)] + pub link_topology: Option, + /// Mark this link as unicast-drained (contributor or foundation) + #[arg(long)] + pub unicast_drained: Option, /// Wait for the device to be activated #[arg(short, long, default_value_t = false)] pub wait: bool, @@ -109,6 +115,21 @@ impl UpdateLinkCliCommand { } } + let link_topologies = if let Some(ref topology_name) = self.link_topology { + if topology_name == "default" { + Some(vec![]) + } else { + let (topology_pda, _) = + doublezero_sdk::get_topology_pda(&client.get_program_id(), topology_name); + client + .get_account(topology_pda) + .map_err(|_| eyre::eyre!("Topology '{}' not found", topology_name))?; + Some(vec![topology_pda]) + } + } else { + None + }; + let signature = client.update_link(UpdateLinkCommand { pubkey, code: self.code.clone(), @@ -127,6 +148,8 @@ impl UpdateLinkCliCommand { desired_status: self.desired_status, tunnel_id: self.tunnel_id, tunnel_net: self.tunnel_net, + link_topologies, + unicast_drained: self.unicast_drained, })?; writeln!(out, "Signature: {signature}",)?; @@ -282,6 +305,8 @@ mod tests { desired_status: None, tunnel_id: None, tunnel_net: None, + link_topologies: None, + unicast_drained: None, })) .returning(move |_| Ok(signature)); @@ -301,6 +326,8 @@ mod tests { desired_status: None, tunnel_id: None, tunnel_net: None, + link_topology: None, + unicast_drained: None, wait: false, } .execute(&client, &mut output); @@ -326,6 +353,8 @@ mod tests { desired_status: None, tunnel_id: None, tunnel_net: None, + link_topology: None, + unicast_drained: None, wait: false, } .execute(&client, &mut output); diff --git a/smartcontract/sdk/rs/src/commands/link/update.rs b/smartcontract/sdk/rs/src/commands/link/update.rs index 192f6ee516..28b3fb82cd 100644 --- a/smartcontract/sdk/rs/src/commands/link/update.rs +++ b/smartcontract/sdk/rs/src/commands/link/update.rs @@ -27,6 +27,8 @@ pub struct UpdateLinkCommand { pub desired_status: Option, pub tunnel_id: Option, pub tunnel_net: Option, + pub link_topologies: Option>, + pub unicast_drained: Option, } impl UpdateLinkCommand { @@ -100,8 +102,8 @@ impl UpdateLinkCommand { tunnel_id: self.tunnel_id, tunnel_net: self.tunnel_net, use_onchain_allocation, - link_topologies: None, - unicast_drained: None, + link_topologies: self.link_topologies.clone(), + unicast_drained: self.unicast_drained, }), accounts, ) From 034e88679013ae8d6e8aa58bc400c1fb1d375dcd Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 11:32:24 -0500 Subject: [PATCH 22/49] cli: resolve topology names in link/tenant get and list display --- smartcontract/cli/src/link/get.rs | 39 +++++++++++++++----- smartcontract/cli/src/link/list.rs | 54 ++++++++++++++++++++++------ smartcontract/cli/src/tenant/get.rs | 39 +++++++++++++++----- smartcontract/cli/src/tenant/list.rs | 47 ++++++++++++++++++------ 4 files changed, 143 insertions(+), 36 deletions(-) diff --git a/smartcontract/cli/src/link/get.rs b/smartcontract/cli/src/link/get.rs index 8236771372..6999a8c16f 100644 --- a/smartcontract/cli/src/link/get.rs +++ b/smartcontract/cli/src/link/get.rs @@ -1,10 +1,10 @@ use crate::{doublezerocommand::CliCommand, validators::validate_code}; use clap::Args; use doublezero_program_common::serializer; -use doublezero_sdk::commands::link::get::GetLinkCommand; +use doublezero_sdk::{commands::link::get::GetLinkCommand, TopologyInfo}; use serde::Serialize; use solana_sdk::pubkey::Pubkey; -use std::io::Write; +use std::{collections::HashMap, io::Write}; use tabled::Tabled; #[derive(Args, Debug)] @@ -51,12 +51,36 @@ struct LinkDisplay { pub unicast_drained: bool, } +fn resolve_topology_names( + pubkeys: &[Pubkey], + topology_map: &HashMap, +) -> String { + if pubkeys.is_empty() { + "default".to_string() + } else { + pubkeys + .iter() + .map(|pk| { + topology_map + .get(pk) + .map(|t| t.name.clone()) + .unwrap_or_else(|| pk.to_string()) + }) + .collect::>() + .join(", ") + } +} + impl GetLinkCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { let (pubkey, link) = client.get_link(GetLinkCommand { pubkey_or_code: self.code, })?; + let topology_map = client + .list_topology(doublezero_sdk::commands::topology::list::ListTopologyCommand) + .unwrap_or_default(); + let display = LinkDisplay { account: pubkey.to_string(), code: link.code, @@ -94,12 +118,7 @@ impl GetLinkCliCommand { status: link.status.to_string(), health: link.link_health.to_string(), owner: link.owner.to_string(), - link_topologies: link - .link_topologies - .iter() - .map(|pk| pk.to_string()) - .collect::>() - .join(", "), + link_topologies: resolve_topology_names(&link.link_topologies, &topology_map), unicast_drained: link.unicast_drained, }; @@ -135,6 +154,7 @@ mod tests { }; use mockall::predicate; use solana_sdk::pubkey::Pubkey; + use std::collections::HashMap; #[test] fn test_cli_link_get() { @@ -252,6 +272,9 @@ mod tests { pubkey_or_code: device2_pk.to_string(), })) .returning(move |_| Ok((device2_pk, device2.clone()))); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); // Expected failure let mut output = Vec::new(); diff --git a/smartcontract/cli/src/link/list.rs b/smartcontract/cli/src/link/list.rs index d2f8f0b81b..cb362463dc 100644 --- a/smartcontract/cli/src/link/list.rs +++ b/smartcontract/cli/src/link/list.rs @@ -6,13 +6,14 @@ use doublezero_sdk::{ contributor::{get::GetContributorCommand, list::ListContributorCommand}, device::list::ListDeviceCommand, link::list::ListLinkCommand, + topology::list::ListTopologyCommand, }, - Link, LinkLinkType, LinkStatus, + Link, LinkLinkType, LinkStatus, TopologyInfo, }; use doublezero_serviceability::state::link::{LinkDesiredStatus, LinkHealth}; use serde::Serialize; use solana_sdk::pubkey::Pubkey; -use std::{io::Write, str::FromStr}; +use std::{collections::HashMap, io::Write, str::FromStr}; use tabled::{settings::Style, Table, Tabled}; #[derive(Args, Debug)] @@ -96,11 +97,34 @@ pub struct LinkDisplay { pub unicast_drained: bool, } +fn resolve_topology_names( + pubkeys: &[Pubkey], + topology_map: &HashMap, +) -> String { + if pubkeys.is_empty() { + "default".to_string() + } else { + pubkeys + .iter() + .map(|pk| { + topology_map + .get(pk) + .map(|t| t.name.clone()) + .unwrap_or_else(|| pk.to_string()) + }) + .collect::>() + .join(", ") + } +} + impl ListLinkCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { let contributors = client.list_contributor(ListContributorCommand {})?; let devices = client.list_device(ListDeviceCommand)?; let mut links = client.list_link(ListLinkCommand)?; + let topology_map = client + .list_topology(ListTopologyCommand) + .unwrap_or_default(); // Filter by contributor if specified if let Some(contributor_filter) = &self.contributor { @@ -219,12 +243,7 @@ impl ListLinkCliCommand { status: link.status, health: link.link_health, owner: link.owner, - link_topologies: link - .link_topologies - .iter() - .map(|pk| pk.to_string()) - .collect::>() - .join(", "), + link_topologies: resolve_topology_names(&link.link_topologies, &topology_map), unicast_drained: link.unicast_drained, } }) @@ -396,6 +415,9 @@ mod tests { tunnels.insert(tunnel1_pubkey, tunnel1.clone()); Ok(tunnels) }); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); let mut output = Vec::new(); let res = ListLinkCliCommand { @@ -416,7 +438,7 @@ mod tests { assert!(res.is_ok()); let output_str = String::from_utf8(output).unwrap(); - assert_eq!(output_str, " account | code | contributor | side_a_name | side_a_iface_name | side_z_name | side_z_iface_name | link_type | bandwidth | mtu | delay_ms | jitter_ms | delay_override_ms | tunnel_id | tunnel_net | status | health | owner | link_topologies | unicast_drained \n 1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR | tunnel_code | contributor1_code | device2_code | eth0 | device2_code | eth1 | WAN | 10Gbps | 4500 | 0.02ms | 0.00ms | 0.00ms | 1234 | 1.2.3.4/32 | activated | ready-for-service | 11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9 | | false \n"); + assert_eq!(output_str, " account | code | contributor | side_a_name | side_a_iface_name | side_z_name | side_z_iface_name | link_type | bandwidth | mtu | delay_ms | jitter_ms | delay_override_ms | tunnel_id | tunnel_net | status | health | owner | link_topologies | unicast_drained \n 1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR | tunnel_code | contributor1_code | device2_code | eth0 | device2_code | eth1 | WAN | 10Gbps | 4500 | 0.02ms | 0.00ms | 0.00ms | 1234 | 1.2.3.4/32 | activated | ready-for-service | 11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9 | default | false \n"); let mut output = Vec::new(); let res = ListLinkCliCommand { @@ -437,7 +459,7 @@ mod tests { assert!(res.is_ok()); let output_str = String::from_utf8(output).unwrap(); - assert_eq!(output_str, "[{\"account\":\"1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR\",\"code\":\"tunnel_code\",\"contributor_code\":\"contributor1_code\",\"side_a_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_a_name\":\"device2_code\",\"side_a_iface_name\":\"eth0\",\"side_z_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_z_name\":\"device2_code\",\"side_z_iface_name\":\"eth1\",\"link_type\":\"WAN\",\"bandwidth\":10000000000,\"mtu\":4500,\"delay_ns\":20000,\"jitter_ns\":1121,\"delay_override_ns\":0,\"tunnel_id\":1234,\"tunnel_net\":\"1.2.3.4/32\",\"desired_status\":\"Activated\",\"status\":\"Activated\",\"health\":\"ReadyForService\",\"owner\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"link_topologies\":\"\",\"unicast_drained\":false}]\n"); + assert_eq!(output_str, "[{\"account\":\"1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR\",\"code\":\"tunnel_code\",\"contributor_code\":\"contributor1_code\",\"side_a_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_a_name\":\"device2_code\",\"side_a_iface_name\":\"eth0\",\"side_z_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_z_name\":\"device2_code\",\"side_z_iface_name\":\"eth1\",\"link_type\":\"WAN\",\"bandwidth\":10000000000,\"mtu\":4500,\"delay_ns\":20000,\"jitter_ns\":1121,\"delay_override_ns\":0,\"tunnel_id\":1234,\"tunnel_net\":\"1.2.3.4/32\",\"desired_status\":\"Activated\",\"status\":\"Activated\",\"health\":\"ReadyForService\",\"owner\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"link_topologies\":\"default\",\"unicast_drained\":false}]\n"); } #[test] @@ -621,6 +643,9 @@ mod tests { tunnels.insert(tunnel2_pubkey, tunnel2.clone()); Ok(tunnels) }); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); let mut output = Vec::new(); let res = ListLinkCliCommand { @@ -800,6 +825,9 @@ mod tests { links.insert(link2_pubkey, link2.clone()); Ok(links) }); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); // Test filter by link_type=WAN (should return only link1) let mut output = Vec::new(); @@ -979,6 +1007,9 @@ mod tests { links.insert(link2_pubkey, link2.clone()); Ok(links) }); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); // Test filter by side_a=device_ams (should return only link1) let mut output = Vec::new(); @@ -1125,6 +1156,9 @@ mod tests { links.insert(link2_pubkey, link2.clone()); Ok(links) }); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); // Test filter by code=production (should return only link1) let mut output = Vec::new(); diff --git a/smartcontract/cli/src/tenant/get.rs b/smartcontract/cli/src/tenant/get.rs index 4b6353353a..fecd472d52 100644 --- a/smartcontract/cli/src/tenant/get.rs +++ b/smartcontract/cli/src/tenant/get.rs @@ -1,10 +1,10 @@ use crate::{doublezerocommand::CliCommand, validators::validate_pubkey_or_code}; use clap::Args; use doublezero_program_common::serializer; -use doublezero_sdk::commands::tenant::get::GetTenantCommand; +use doublezero_sdk::{commands::tenant::get::GetTenantCommand, TopologyInfo}; use serde::Serialize; use solana_sdk::pubkey::Pubkey; -use std::io::Write; +use std::{collections::HashMap, io::Write}; use tabled::Tabled; #[derive(Args, Debug)] @@ -35,12 +35,36 @@ struct TenantDisplay { pub owner: Pubkey, } +fn resolve_topology_names( + pubkeys: &[Pubkey], + topology_map: &HashMap, +) -> String { + if pubkeys.is_empty() { + "default".to_string() + } else { + pubkeys + .iter() + .map(|pk| { + topology_map + .get(pk) + .map(|t| t.name.clone()) + .unwrap_or_else(|| pk.to_string()) + }) + .collect::>() + .join(", ") + } +} + impl GetTenantCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { let (pubkey, tenant) = client.get_tenant(GetTenantCommand { pubkey_or_code: self.code, })?; + let topology_map = client + .list_topology(doublezero_sdk::commands::topology::list::ListTopologyCommand) + .unwrap_or_default(); + let display = TenantDisplay { account: pubkey, code: tenant.code, @@ -57,12 +81,7 @@ impl GetTenantCliCommand { .join(", "), token_account: tenant.token_account.to_string(), reference_count: tenant.reference_count, - include_topologies: tenant - .include_topologies - .iter() - .map(|pk| pk.to_string()) - .collect::>() - .join(", "), + include_topologies: resolve_topology_names(&tenant.include_topologies, &topology_map), owner: tenant.owner, }; @@ -91,6 +110,7 @@ mod tests { }; use mockall::predicate; use solana_sdk::pubkey::Pubkey; + use std::collections::HashMap; #[test] fn test_cli_tenant_get() { @@ -130,6 +150,9 @@ mod tests { client .expect_get_tenant() .returning(move |_| Err(eyre::eyre!("not found"))); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); /*****************************************************************************************************/ // Expected failure diff --git a/smartcontract/cli/src/tenant/list.rs b/smartcontract/cli/src/tenant/list.rs index 6740eeee5d..356c6f3ea5 100644 --- a/smartcontract/cli/src/tenant/list.rs +++ b/smartcontract/cli/src/tenant/list.rs @@ -1,10 +1,13 @@ use crate::doublezerocommand::CliCommand; use clap::Args; use doublezero_program_common::serializer; -use doublezero_sdk::commands::tenant::list::ListTenantCommand; +use doublezero_sdk::{ + commands::{tenant::list::ListTenantCommand, topology::list::ListTopologyCommand}, + TopologyInfo, +}; use serde::Serialize; use solana_sdk::pubkey::Pubkey; -use std::io::Write; +use std::{collections::HashMap, io::Write}; use tabled::{settings::Style, Table, Tabled}; #[derive(Args, Debug)] @@ -30,9 +33,32 @@ pub struct TenantDisplay { pub owner: Pubkey, } +fn resolve_topology_names( + pubkeys: &[Pubkey], + topology_map: &HashMap, +) -> String { + if pubkeys.is_empty() { + "default".to_string() + } else { + pubkeys + .iter() + .map(|pk| { + topology_map + .get(pk) + .map(|t| t.name.clone()) + .unwrap_or_else(|| pk.to_string()) + }) + .collect::>() + .join(", ") + } +} + impl ListTenantCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { let tenants = client.list_tenant(ListTenantCommand {})?; + let topology_map = client + .list_topology(ListTopologyCommand) + .unwrap_or_default(); let mut tenant_displays: Vec = tenants .into_iter() @@ -42,12 +68,10 @@ impl ListTenantCliCommand { vrf_id: tenant.vrf_id, metro_routing: tenant.metro_routing, route_liveness: tenant.route_liveness, - include_topologies: tenant - .include_topologies - .iter() - .map(|pk| pk.to_string()) - .collect::>() - .join(", "), + include_topologies: resolve_topology_names( + &tenant.include_topologies, + &topology_map, + ), owner: tenant.owner, }) .collect(); @@ -104,6 +128,9 @@ mod tests { client .expect_list_tenant() .returning(move |_| Ok(HashMap::from([(tenant1_pubkey, tenant1.clone())]))); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); /*****************************************************************************************************/ let mut output = Vec::new(); @@ -116,7 +143,7 @@ mod tests { let output_str = String::from_utf8(output).unwrap(); assert_eq!( output_str, - " account | code | vrf_id | metro_routing | route_liveness | include_topologies | owner \n 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo | tenant-a | 100 | true | false | | 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo \n" + " account | code | vrf_id | metro_routing | route_liveness | include_topologies | owner \n 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo | tenant-a | 100 | true | false | default | 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo \n" ); let mut output = Vec::new(); @@ -129,7 +156,7 @@ mod tests { let output_str = String::from_utf8(output).unwrap(); assert_eq!( output_str, - "[{\"account\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\",\"code\":\"tenant-a\",\"vrf_id\":100,\"metro_routing\":true,\"route_liveness\":false,\"include_topologies\":\"\",\"owner\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\"}]\n" + "[{\"account\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\",\"code\":\"tenant-a\",\"vrf_id\":100,\"metro_routing\":true,\"route_liveness\":false,\"include_topologies\":\"default\",\"owner\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\"}]\n" ); } } From a7b4d43081b3d70de93375afde205e995dd2112e Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 11:45:44 -0500 Subject: [PATCH 23/49] cli: add doublezero-admin migrate command for RFC-18 link topology backfill --- .../doublezero-admin/src/cli/command.rs | 7 +- .../doublezero-admin/src/cli/migrate.rs | 130 ++++++++++++++++++ controlplane/doublezero-admin/src/cli/mod.rs | 1 + controlplane/doublezero-admin/src/main.rs | 1 + 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 controlplane/doublezero-admin/src/cli/migrate.rs diff --git a/controlplane/doublezero-admin/src/cli/command.rs b/controlplane/doublezero-admin/src/cli/command.rs index 7c4ad46947..7090287b4c 100644 --- a/controlplane/doublezero-admin/src/cli/command.rs +++ b/controlplane/doublezero-admin/src/cli/command.rs @@ -2,8 +2,8 @@ use super::multicast::MulticastCliCommand; use crate::cli::{ accesspass::AccessPassCliCommand, config::ConfigCliCommand, contributor::ContributorCliCommand, device::DeviceCliCommand, exchange::ExchangeCliCommand, globalconfig::GlobalConfigCliCommand, - link::LinkCliCommand, location::LocationCliCommand, permission::PermissionCliCommand, - tenant::TenantCliCommand, user::UserCliCommand, + link::LinkCliCommand, location::LocationCliCommand, migrate::MigrateCliCommand, + permission::PermissionCliCommand, tenant::TenantCliCommand, user::UserCliCommand, }; use clap::{Args, Subcommand}; use clap_complete::Shell; @@ -66,6 +66,9 @@ pub enum Command { /// Manage multicast #[command()] Multicast(MulticastCliCommand), + /// Backfill link topologies and report Vpnv4 loopback gaps (RFC-18 migration) + #[command()] + Migrate(MigrateCliCommand), /// Export all data to files #[command()] Export(ExportCliCommand), diff --git a/controlplane/doublezero-admin/src/cli/migrate.rs b/controlplane/doublezero-admin/src/cli/migrate.rs new file mode 100644 index 0000000000..0414a5b54e --- /dev/null +++ b/controlplane/doublezero-admin/src/cli/migrate.rs @@ -0,0 +1,130 @@ +use clap::Args; +use doublezero_cli::doublezerocommand::CliCommand; +use doublezero_sdk::commands::{ + device::list::ListDeviceCommand, + link::{list::ListLinkCommand, update::UpdateLinkCommand}, + topology::list::ListTopologyCommand, +}; +use doublezero_serviceability::{pda::get_topology_pda, state::interface::LoopbackType}; +use solana_sdk::pubkey::Pubkey; +use std::{collections::HashSet, io::Write}; + +#[derive(Args, Debug)] +pub struct MigrateCliCommand { + /// Print what would be changed without submitting transactions + #[arg(long, default_value_t = false)] + pub dry_run: bool, +} + +impl MigrateCliCommand { + pub fn execute(&self, client: &C, out: &mut W) -> eyre::Result<()> { + let program_id = client.get_program_id(); + + // Verify UNICAST-DEFAULT topology PDA exists on chain. + let (unicast_default_pda, _) = get_topology_pda(&program_id, "UNICAST-DEFAULT"); + client + .get_account(unicast_default_pda) + .map_err(|_| eyre::eyre!("UNICAST-DEFAULT topology PDA {unicast_default_pda} not found on chain — cannot proceed"))?; + + // ── Part 1: link topology backfill ─────────────────────────────────────── + + let links = client.list_link(ListLinkCommand)?; + let mut links_tagged = 0u32; + let mut links_skipped = 0u32; + + let mut link_entries: Vec<(Pubkey, _)> = links.into_iter().collect(); + link_entries.sort_by_key(|(pk, _)| pk.to_string()); + + for (pubkey, link) in &link_entries { + if link.link_topologies.is_empty() { + writeln!( + out, + " [link] {pubkey} ({}) — would tag UNICAST-DEFAULT", + link.code + )?; + if !self.dry_run { + let result = client.update_link(UpdateLinkCommand { + pubkey: *pubkey, + code: None, + contributor_pk: None, + tunnel_type: None, + bandwidth: None, + mtu: None, + delay_ns: None, + jitter_ns: None, + delay_override_ns: None, + status: None, + desired_status: None, + tunnel_id: None, + tunnel_net: None, + link_topologies: Some(vec![unicast_default_pda]), + unicast_drained: None, + }); + match result { + Ok(sig) => { + links_tagged += 1; + writeln!(out, " tagged: {sig}")?; + } + Err(e) => { + writeln!(out, " WARNING: failed to tag {pubkey}: {e}")?; + } + } + } else { + links_tagged += 1; + } + } else { + links_skipped += 1; + } + } + + // ── Part 2: Vpnv4 loopback gap reporting ───────────────────────────────── + + let topologies = client.list_topology(ListTopologyCommand)?; + let topology_pubkeys: HashSet = topologies.keys().copied().collect(); + + let devices = client.list_device(ListDeviceCommand)?; + let mut loopbacks_with_gaps = 0u32; + + let mut device_entries: Vec<(Pubkey, _)> = devices.into_iter().collect(); + device_entries.sort_by_key(|(pk, _)| pk.to_string()); + + for (device_pubkey, device) in &device_entries { + for iface in &device.interfaces { + let current = iface.into_current_version(); + if current.loopback_type != LoopbackType::Vpnv4 { + continue; + } + + let present: HashSet = current + .flex_algo_node_segments + .iter() + .map(|seg| seg.topology) + .collect(); + + let missing_count = topology_pubkeys.difference(&present).count(); + if missing_count > 0 { + loopbacks_with_gaps += 1; + writeln!( + out, + " [loopback] {device_pubkey} iface={} — missing {missing_count} topology entries; re-create topology with device accounts to backfill", + current.name + )?; + } + } + } + + // ── Summary ────────────────────────────────────────────────────────────── + + let dry_run_suffix = if self.dry_run { + " [DRY RUN — no changes made]" + } else { + "" + }; + writeln!( + out, + "\nMigration complete: {links_tagged} links tagged, {links_skipped} links skipped, {loopbacks_with_gaps} loopbacks with gaps{dry_run_suffix}" + )?; + + Ok(()) + } +} diff --git a/controlplane/doublezero-admin/src/cli/mod.rs b/controlplane/doublezero-admin/src/cli/mod.rs index 02ca83a5a6..f4e28ee56e 100644 --- a/controlplane/doublezero-admin/src/cli/mod.rs +++ b/controlplane/doublezero-admin/src/cli/mod.rs @@ -7,6 +7,7 @@ pub mod exchange; pub mod globalconfig; pub mod link; pub mod location; +pub mod migrate; pub mod multicast; pub mod multicastgroup; pub mod permission; diff --git a/controlplane/doublezero-admin/src/main.rs b/controlplane/doublezero-admin/src/main.rs index a2dc1b0fd3..27013c8854 100644 --- a/controlplane/doublezero-admin/src/main.rs +++ b/controlplane/doublezero-admin/src/main.rs @@ -247,6 +247,7 @@ async fn main() -> eyre::Result<()> { }, }, + Command::Migrate(args) => args.execute(&client, &mut handle), Command::Export(args) => args.execute(&client, &mut handle), Command::Keygen(args) => args.execute(&client, &mut handle), Command::Log(args) => args.execute(&dzclient, &mut handle), From baebb1050a9ac93cbacc0b875b173cc1e1985cb1 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 11:56:29 -0500 Subject: [PATCH 24/49] =?UTF-8?q?cli:=20fix=20migrate=20command=20?= =?UTF-8?q?=E2=80=94=20unicast-default=20seed=20case,=20dry-run=20counter?= =?UTF-8?q?=20semantics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controlplane/doublezero-admin/src/cli/migrate.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/controlplane/doublezero-admin/src/cli/migrate.rs b/controlplane/doublezero-admin/src/cli/migrate.rs index 0414a5b54e..57ec0f9b2f 100644 --- a/controlplane/doublezero-admin/src/cli/migrate.rs +++ b/controlplane/doublezero-admin/src/cli/migrate.rs @@ -21,7 +21,7 @@ impl MigrateCliCommand { let program_id = client.get_program_id(); // Verify UNICAST-DEFAULT topology PDA exists on chain. - let (unicast_default_pda, _) = get_topology_pda(&program_id, "UNICAST-DEFAULT"); + let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); client .get_account(unicast_default_pda) .map_err(|_| eyre::eyre!("UNICAST-DEFAULT topology PDA {unicast_default_pda} not found on chain — cannot proceed"))?; @@ -30,6 +30,7 @@ impl MigrateCliCommand { let links = client.list_link(ListLinkCommand)?; let mut links_tagged = 0u32; + let mut links_needing_tag = 0u32; let mut links_skipped = 0u32; let mut link_entries: Vec<(Pubkey, _)> = links.into_iter().collect(); @@ -37,6 +38,7 @@ impl MigrateCliCommand { for (pubkey, link) in &link_entries { if link.link_topologies.is_empty() { + links_needing_tag += 1; writeln!( out, " [link] {pubkey} ({}) — would tag UNICAST-DEFAULT", @@ -69,8 +71,6 @@ impl MigrateCliCommand { writeln!(out, " WARNING: failed to tag {pubkey}: {e}")?; } } - } else { - links_tagged += 1; } } else { links_skipped += 1; @@ -120,9 +120,14 @@ impl MigrateCliCommand { } else { "" }; + let tagged_summary = if self.dry_run { + format!("{links_needing_tag} link(s) would be tagged") + } else { + format!("{links_tagged} link(s) tagged") + }; writeln!( out, - "\nMigration complete: {links_tagged} links tagged, {links_skipped} links skipped, {loopbacks_with_gaps} loopbacks with gaps{dry_run_suffix}" + "\nMigration complete: {tagged_summary}, {links_skipped} link(s) skipped, {loopbacks_with_gaps} loopback(s) with gaps{dry_run_suffix}" )?; Ok(()) From 8ac6c2eb5ae76f9e044fafed9c5be0e2da113b20 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 12:20:26 -0500 Subject: [PATCH 25/49] cli: add AdminGroupBits to resource CLI type enum --- smartcontract/cli/src/resource/allocate.rs | 3 ++- smartcontract/cli/src/resource/deallocate.rs | 3 ++- smartcontract/cli/src/resource/mod.rs | 8 ++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/smartcontract/cli/src/resource/allocate.rs b/smartcontract/cli/src/resource/allocate.rs index 2e55d0d364..e5c87fcaaf 100644 --- a/smartcontract/cli/src/resource/allocate.rs +++ b/smartcontract/cli/src/resource/allocate.rs @@ -40,7 +40,8 @@ impl From for AllocateResourceCommand { | ResourceType::DzPrefixBlock => { IdOrIp::Ip(x.parse::().expect("Failed to parse IP address")) } - ResourceType::TunnelIds + ResourceType::AdminGroupBits + | ResourceType::TunnelIds | ResourceType::LinkIds | ResourceType::SegmentRoutingIds | ResourceType::VrfIds => IdOrIp::Id(x.parse::().expect("Failed to parse ID")), diff --git a/smartcontract/cli/src/resource/deallocate.rs b/smartcontract/cli/src/resource/deallocate.rs index 21cc73f360..e0af19f55c 100644 --- a/smartcontract/cli/src/resource/deallocate.rs +++ b/smartcontract/cli/src/resource/deallocate.rs @@ -42,7 +42,8 @@ impl From for DeallocateResourceCommand { .parse::() .expect("Failed to parse IP address"), ), - ResourceType::TunnelIds + ResourceType::AdminGroupBits + | ResourceType::TunnelIds | ResourceType::LinkIds | ResourceType::SegmentRoutingIds | ResourceType::VrfIds => { diff --git a/smartcontract/cli/src/resource/mod.rs b/smartcontract/cli/src/resource/mod.rs index dfddc61591..4b6008c4c6 100644 --- a/smartcontract/cli/src/resource/mod.rs +++ b/smartcontract/cli/src/resource/mod.rs @@ -11,6 +11,7 @@ pub mod verify; #[derive(Clone, Copy, Debug, ValueEnum)] pub enum ResourceType { + AdminGroupBits, DeviceTunnelBlock, UserTunnelBlock, MulticastGroupBlock, @@ -28,6 +29,7 @@ pub fn resource_type_from( index: Option, ) -> SdkResourceType { match ext { + ResourceType::AdminGroupBits => SdkResourceType::AdminGroupBits, ResourceType::DeviceTunnelBlock => SdkResourceType::DeviceTunnelBlock, ResourceType::UserTunnelBlock => SdkResourceType::UserTunnelBlock, ResourceType::MulticastGroupBlock => SdkResourceType::MulticastGroupBlock, @@ -75,6 +77,12 @@ mod tests { use super::*; use solana_program::pubkey::Pubkey; + #[test] + fn test_admin_group_bits() { + let result = resource_type_from(ResourceType::AdminGroupBits, None, None); + assert_eq!(result, SdkResourceType::AdminGroupBits); + } + #[test] fn test_device_tunnel_block() { let result = resource_type_from(ResourceType::DeviceTunnelBlock, None, None); From 27101d0d5bd78ebf18011e22a0c8b0f6830e26b0 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 13:57:52 -0500 Subject: [PATCH 26/49] smartcontract: create AdminGroupBits in global-config set Wire AdminGroupBits resource creation into process_set_globalconfig, following the same pattern as DeviceTunnelBlock, LinkIds, etc. The PDA is created on first initialization and a migration path handles existing deployments that predate RFC-18. Remove the separate create_admin_group_bits test helper and replace all call sites with a plain PDA derivation; update all SetGlobalConfig account lists to include admin_group_bits_pda as the 9th account. --- .../src/processors/globalconfig/set.rs | 32 +++ .../tests/accesspass_allow_multiple_ip.rs | 3 + .../tests/create_subscribe_user_test.rs | 12 + .../tests/device_test.rs | 6 + .../tests/device_update_location_test.rs | 3 + .../tests/exchange_setdevice.rs | 3 + .../tests/exchange_test.rs | 16 ++ .../tests/global_test.rs | 3 + .../tests/interface_test.rs | 3 + .../tests/link_dzx_test.rs | 3 + .../tests/link_wan_test.rs | 12 + .../tests/multicastgroup_subscribe_test.rs | 3 + .../tests/test_helpers.rs | 33 +-- .../tests/topology_test.rs | 221 ++++-------------- .../tests/user_migration.rs | 3 + .../tests/user_old_test.rs | 3 + .../tests/user_onchain_allocation_test.rs | 6 + .../tests/user_tests.rs | 9 + .../sdk/rs/src/commands/globalconfig/set.rs | 3 + 19 files changed, 174 insertions(+), 203 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/globalconfig/set.rs b/smartcontract/programs/doublezero-serviceability/src/processors/globalconfig/set.rs index 5ce66bd448..c4f41e6645 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/globalconfig/set.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/globalconfig/set.rs @@ -65,6 +65,7 @@ pub fn process_set_globalconfig( let segment_routing_ids_account = next_account_info(accounts_iter)?; let multicast_publisher_block_account = next_account_info(accounts_iter)?; let vrf_ids_account = next_account_info(accounts_iter)?; + let admin_group_bits_account = next_account_info(accounts_iter)?; let payer_account = next_account_info(accounts_iter)?; let system_program = next_account_info(accounts_iter)?; @@ -104,6 +105,8 @@ pub fn process_set_globalconfig( get_resource_extension_pda(program_id, ResourceType::MulticastGroupBlock); let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(program_id, ResourceType::MulticastPublisherBlock); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(program_id, ResourceType::AdminGroupBits); assert_eq!( device_tunnel_block_account.key, &device_tunnel_block_pda, @@ -125,6 +128,11 @@ pub fn process_set_globalconfig( "Invalid Multicast Publisher Block PubKey" ); + assert_eq!( + admin_group_bits_account.key, &admin_group_bits_pda, + "Invalid AdminGroupBits PubKey" + ); + let next_bgp_community = if let Some(val) = value.next_bgp_community { val } else if pda_account.try_borrow_data()?.is_empty() { @@ -230,6 +238,16 @@ pub fn process_set_globalconfig( accounts, ResourceType::VrfIds, )?; + + create_resource( + program_id, + admin_group_bits_account, + None, + pda_account, + payer_account, + accounts, + ResourceType::AdminGroupBits, + )?; } else { let old_data = GlobalConfig::try_from(pda_account)?; if old_data.device_tunnel_block != data.device_tunnel_block { @@ -266,6 +284,20 @@ pub fn process_set_globalconfig( ResourceType::MulticastPublisherBlock, )?; } + + // Create AdminGroupBits PDA if it doesn't exist yet (migration support for + // deployments that predate RFC-18). + if admin_group_bits_account.data_is_empty() { + create_resource( + program_id, + admin_group_bits_account, + None, + pda_account, + payer_account, + accounts, + ResourceType::AdminGroupBits, + )?; + } } #[cfg(test)] diff --git a/smartcontract/programs/doublezero-serviceability/tests/accesspass_allow_multiple_ip.rs b/smartcontract/programs/doublezero-serviceability/tests/accesspass_allow_multiple_ip.rs index fdffaa5bc6..f4d63858f8 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/accesspass_allow_multiple_ip.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/accesspass_allow_multiple_ip.rs @@ -63,6 +63,8 @@ async fn test_accesspass_allow_multiple_ip() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -87,6 +89,7 @@ async fn test_accesspass_allow_multiple_ip() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs b/smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs index 01ea8dae72..b05c9562cd 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs @@ -109,6 +109,8 @@ async fn setup_create_subscribe_fixture(client_ip: [u8; 4]) -> CreateSubscribeFi let (multicast_publisher_block, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Init global state execute_transaction( @@ -148,6 +150,7 @@ async fn setup_create_subscribe_fixture(client_ip: [u8; 4]) -> CreateSubscribeFi AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1546,6 +1549,8 @@ async fn test_create_subscribe_user_foundation_owner_override() { let (multicast_publisher_block, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Init global state (payer is automatically in foundation_allowlist) execute_transaction( @@ -1585,6 +1590,7 @@ async fn test_create_subscribe_user_foundation_owner_override() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1886,6 +1892,8 @@ async fn test_create_subscribe_user_sentinel_owner_override() { let (multicast_publisher_block, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Init global state execute_transaction( @@ -1942,6 +1950,7 @@ async fn test_create_subscribe_user_sentinel_owner_override() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -2242,6 +2251,8 @@ async fn test_create_subscribe_user_non_foundation_owner_override_rejected() { let (multicast_publisher_block, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Init global state with foundation payer execute_transaction( @@ -2281,6 +2292,7 @@ async fn test_create_subscribe_user_non_foundation_owner_override_rejected() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/device_test.rs b/smartcontract/programs/doublezero-serviceability/tests/device_test.rs index b825865975..0a2b9812e8 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/device_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/device_test.rs @@ -75,6 +75,8 @@ async fn test_device() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, recent_blockhash, @@ -98,6 +100,7 @@ async fn test_device() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1058,6 +1061,8 @@ async fn setup_program_with_location_and_exchange( let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -1082,6 +1087,7 @@ async fn setup_program_with_location_and_exchange( AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/device_update_location_test.rs b/smartcontract/programs/doublezero-serviceability/tests/device_update_location_test.rs index 133cc3d871..3ce9fd61ed 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/device_update_location_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/device_update_location_test.rs @@ -64,6 +64,8 @@ async fn device_update_location_test() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, recent_blockhash, @@ -87,6 +89,7 @@ async fn device_update_location_test() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/exchange_setdevice.rs b/smartcontract/programs/doublezero-serviceability/tests/exchange_setdevice.rs index 55e237f29a..1c16f6c51c 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/exchange_setdevice.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/exchange_setdevice.rs @@ -57,6 +57,8 @@ async fn exchange_setdevice() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, recent_blockhash, @@ -80,6 +82,7 @@ async fn exchange_setdevice() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/exchange_test.rs b/smartcontract/programs/doublezero-serviceability/tests/exchange_test.rs index 7c5d1b576f..66ee1f458a 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/exchange_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/exchange_test.rs @@ -55,6 +55,8 @@ async fn test_exchange() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -79,6 +81,7 @@ async fn test_exchange() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -283,6 +286,8 @@ async fn test_exchange_delete_from_suspended() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -307,6 +312,7 @@ async fn test_exchange_delete_from_suspended() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -409,6 +415,8 @@ async fn test_exchange_owner_and_foundation_can_update_status() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, recent_blockhash, @@ -432,6 +440,7 @@ async fn test_exchange_owner_and_foundation_can_update_status() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -550,6 +559,8 @@ async fn test_exchange_bgp_community_autoassignment() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); println!("Initializing global state..."); execute_transaction( @@ -589,6 +600,7 @@ async fn test_exchange_bgp_community_autoassignment() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -746,6 +758,7 @@ async fn test_exchange_bgp_community_autoassignment() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -857,6 +870,8 @@ async fn test_suspend_exchange_from_suspended_fails() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Initialize global state execute_transaction( @@ -896,6 +911,7 @@ async fn test_suspend_exchange_from_suspended_fails() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/global_test.rs b/smartcontract/programs/doublezero-serviceability/tests/global_test.rs index 6bdd7ccc26..c2378a847d 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/global_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/global_test.rs @@ -74,6 +74,8 @@ async fn test_doublezero_program() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -98,6 +100,7 @@ async fn test_doublezero_program() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs b/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs index a04e3ad92f..77b6ac3d56 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs @@ -70,6 +70,8 @@ async fn test_device_interfaces() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, recent_blockhash, @@ -93,6 +95,7 @@ async fn test_device_interfaces() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs index e9fb4abfa3..3d5d3bd6b9 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs @@ -64,6 +64,8 @@ async fn test_dzx_link() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -88,6 +90,7 @@ async fn test_dzx_link() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index f4bb52209c..01d9b8f9ca 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -63,6 +63,8 @@ async fn test_wan_link() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -87,6 +89,7 @@ async fn test_wan_link() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -910,6 +913,8 @@ async fn test_wan_link_rejects_cyoa_interface() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -934,6 +939,7 @@ async fn test_wan_link_rejects_cyoa_interface() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1480,6 +1486,8 @@ async fn test_cannot_set_cyoa_on_linked_interface() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -1504,6 +1512,7 @@ async fn test_cannot_set_cyoa_on_linked_interface() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1941,6 +1950,8 @@ async fn setup_link_env() -> ( let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -1965,6 +1976,7 @@ async fn setup_link_env() -> ( AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs b/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs index b8f54c962e..e9da14d033 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs @@ -90,6 +90,8 @@ async fn setup_fixture() -> TestFixture { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -114,6 +116,7 @@ async fn setup_fixture() -> TestFixture { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs b/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs index 025a337dcc..75ff06981e 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs @@ -6,10 +6,7 @@ use doublezero_serviceability::{ get_globalconfig_pda, get_globalstate_pda, get_program_config_pda, get_resource_extension_pda, get_topology_pda, }, - processors::{ - globalconfig::set::SetGlobalConfigArgs, resource::create::ResourceCreateArgs, - topology::create::TopologyCreateArgs, - }, + processors::{globalconfig::set::SetGlobalConfigArgs, topology::create::TopologyCreateArgs}, resource::ResourceType, state::{ accountdata::AccountData, accounttype::AccountType, device::Device, @@ -413,6 +410,8 @@ pub async fn setup_program_with_globalconfig() -> (BanksClient, Keypair, Pubkey, let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Initialize global state execute_transaction( @@ -452,6 +451,7 @@ pub async fn setup_program_with_globalconfig() -> (BanksClient, Keypair, Pubkey, AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -466,37 +466,20 @@ pub async fn setup_program_with_globalconfig() -> (BanksClient, Keypair, Pubkey, ) } -/// Create the AdminGroupBits resource extension and the "unicast-default" topology. +/// Create the "unicast-default" topology. /// Returns the PDA of the "unicast-default" topology. -/// Requires that global state + global config are already initialized. +/// Requires that global state + global config are already initialized (AdminGroupBits is +/// created by SetGlobalConfig). #[allow(dead_code)] pub async fn create_unicast_default_topology( banks_client: &mut BanksClient, program_id: Pubkey, globalstate_pubkey: Pubkey, - globalconfig_pubkey: Pubkey, + _globalconfig_pubkey: Pubkey, payer: &Keypair, ) -> Pubkey { let (admin_group_bits_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); - let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); - - execute_transaction( - banks_client, - recent_blockhash, - program_id, - DoubleZeroInstruction::CreateResource(ResourceCreateArgs { - resource_type: ResourceType::AdminGroupBits, - }), - vec![ - AccountMeta::new(admin_group_bits_pda, false), - AccountMeta::new(Pubkey::default(), false), - AccountMeta::new(globalstate_pubkey, false), - AccountMeta::new(globalconfig_pubkey, false), - ], - payer, - ) - .await; let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index fcc12595dd..aa0fc443d7 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -19,7 +19,6 @@ use doublezero_serviceability::{ exchange::create::ExchangeCreateArgs, link::{activate::LinkActivateArgs, create::LinkCreateArgs, update::LinkUpdateArgs}, location::create::LocationCreateArgs, - resource::create::ResourceCreateArgs, topology::{ clear::TopologyClearArgs, create::TopologyCreateArgs, delete::TopologyDeleteArgs, }, @@ -43,37 +42,6 @@ use solana_sdk::{ mod test_helpers; use test_helpers::*; -/// Creates the AdminGroupBits resource extension. -/// Requires that global state + global config are already initialized. -async fn create_admin_group_bits( - banks_client: &mut BanksClient, - program_id: Pubkey, - globalstate_pubkey: Pubkey, - globalconfig_pubkey: Pubkey, - payer: &Keypair, -) -> Pubkey { - let (resource_pubkey, _, _) = - get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); - let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); - execute_transaction( - banks_client, - recent_blockhash, - program_id, - DoubleZeroInstruction::CreateResource(ResourceCreateArgs { - resource_type: ResourceType::AdminGroupBits, - }), - vec![ - AccountMeta::new(resource_pubkey, false), - AccountMeta::new(Pubkey::default(), false), - AccountMeta::new(globalstate_pubkey, false), - AccountMeta::new(globalconfig_pubkey, false), - ], - payer, - ) - .await; - resource_pubkey -} - /// Helper that creates the topology using the standard account layout. async fn create_topology( banks_client: &mut BanksClient, @@ -117,32 +85,13 @@ async fn get_topology(banks_client: &mut BanksClient, pubkey: Pubkey) -> Topolog async fn test_admin_group_bits_create_and_pre_mark() { println!("[TEST] test_admin_group_bits_create_and_pre_mark"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + // AdminGroupBits is created automatically by SetGlobalConfig (via setup_program_with_globalconfig). + let (mut banks_client, _payer, program_id, _globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); - let (resource_pubkey, _, _) = get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); - // Create the AdminGroupBits resource extension - execute_transaction( - &mut banks_client, - recent_blockhash, - program_id, - DoubleZeroInstruction::CreateResource(ResourceCreateArgs { - resource_type: ResourceType::AdminGroupBits, - }), - vec![ - AccountMeta::new(resource_pubkey, false), - AccountMeta::new(Pubkey::default(), false), // associated_account (not used) - AccountMeta::new(globalstate_pubkey, false), - AccountMeta::new(globalconfig_pubkey, false), - ], - &payer, - ) - .await; - // Verify the account was created and has data let account = banks_client .get_account(resource_pubkey) @@ -213,17 +162,11 @@ fn test_flex_algo_node_segment_roundtrip() { async fn test_topology_create_bit_0_first() { println!("[TEST] test_topology_create_bit_0_first"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); let topology_pda = create_topology( &mut banks_client, @@ -251,17 +194,11 @@ async fn test_topology_create_bit_0_first() { async fn test_topology_create_second_skips_bit_1() { println!("[TEST] test_topology_create_second_skips_bit_1"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // First topology gets bit 0 create_topology( @@ -303,17 +240,11 @@ async fn test_topology_create_second_skips_bit_1() { async fn test_topology_create_non_foundation_rejected() { println!("[TEST] test_topology_create_non_foundation_rejected"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Use a keypair that is NOT in the foundation allowlist let non_foundation = Keypair::new(); @@ -362,17 +293,11 @@ async fn test_topology_create_non_foundation_rejected() { async fn test_topology_create_name_too_long_rejected() { println!("[TEST] test_topology_create_name_too_long_rejected"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // 33-char name exceeds MAX_TOPOLOGY_NAME_LEN=32 // We use a dummy pubkey for the topology PDA since the name validation fires @@ -417,17 +342,11 @@ async fn test_topology_create_name_too_long_rejected() { async fn test_topology_create_duplicate_rejected() { println!("[TEST] test_topology_create_duplicate_rejected"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // First creation succeeds create_topology( @@ -484,14 +403,8 @@ async fn test_topology_create_backfills_vpnv4_loopbacks() { let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); // Create AdminGroupBits and SegmentRoutingIds resources - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); let (segment_routing_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); @@ -1176,17 +1089,11 @@ async fn assign_link_topology( async fn test_topology_delete_succeeds_when_no_links() { println!("[TEST] test_topology_delete_succeeds_when_no_links"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); let topology_pda = create_topology( &mut banks_client, @@ -1229,17 +1136,11 @@ async fn test_topology_delete_succeeds_when_no_links() { async fn test_topology_delete_fails_when_link_references_it() { println!("[TEST] test_topology_delete_fails_when_link_references_it"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); let topology_pda = create_topology( &mut banks_client, @@ -1313,17 +1214,11 @@ async fn test_topology_delete_fails_when_link_references_it() { async fn test_topology_delete_bit_not_reused() { println!("[TEST] test_topology_delete_bit_not_reused"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Create "topology-a" — gets bit 0 create_topology( @@ -1379,17 +1274,11 @@ async fn test_topology_delete_bit_not_reused() { async fn test_topology_clear_removes_from_links() { println!("[TEST] test_topology_clear_removes_from_links"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); let topology_pda = create_topology( &mut banks_client, @@ -1459,17 +1348,11 @@ async fn test_topology_clear_removes_from_links() { async fn test_topology_clear_is_idempotent() { println!("[TEST] test_topology_clear_is_idempotent"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); let test_topology_pda = create_topology( &mut banks_client, @@ -1538,17 +1421,11 @@ async fn test_topology_clear_is_idempotent() { async fn test_topology_delete_non_foundation_rejected() { println!("[TEST] test_topology_delete_non_foundation_rejected"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Create topology with foundation payer create_topology( @@ -1599,17 +1476,11 @@ async fn test_topology_delete_non_foundation_rejected() { async fn test_topology_clear_non_foundation_rejected() { println!("[TEST] test_topology_clear_non_foundation_rejected"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Create topology with foundation payer create_topology( @@ -1842,17 +1713,11 @@ async fn test_link_unicast_drained_foundation_can_set_any_link() { async fn test_link_unicast_drained_orthogonal_to_status_and_topologies() { println!("[TEST] test_link_unicast_drained_orthogonal_to_status_and_topologies"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); let topology_pda = create_topology( &mut banks_client, diff --git a/smartcontract/programs/doublezero-serviceability/tests/user_migration.rs b/smartcontract/programs/doublezero-serviceability/tests/user_migration.rs index b265b91ad5..2669f46239 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/user_migration.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/user_migration.rs @@ -59,6 +59,8 @@ async fn test_user_migration() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -83,6 +85,7 @@ async fn test_user_migration() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/user_old_test.rs b/smartcontract/programs/doublezero-serviceability/tests/user_old_test.rs index d2bd69ca1d..60ee4a144a 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/user_old_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/user_old_test.rs @@ -63,6 +63,8 @@ async fn test_old_user() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -87,6 +89,7 @@ async fn test_old_user() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/user_onchain_allocation_test.rs b/smartcontract/programs/doublezero-serviceability/tests/user_onchain_allocation_test.rs index 1fd5eff0c1..5c1ecd2c1c 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/user_onchain_allocation_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/user_onchain_allocation_test.rs @@ -108,6 +108,8 @@ async fn setup_user_onchain_allocation_test( let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Initialize global state execute_transaction( @@ -147,6 +149,7 @@ async fn setup_user_onchain_allocation_test( AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1965,6 +1968,8 @@ async fn setup_user_infra_without_user( let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Initialize global state execute_transaction( @@ -2004,6 +2009,7 @@ async fn setup_user_infra_without_user( AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs b/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs index 6997f56f22..75debb3453 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs @@ -66,6 +66,8 @@ async fn test_user() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -90,6 +92,7 @@ async fn test_user() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -564,6 +567,8 @@ async fn test_user_ban_requires_pendingban() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, recent_blockhash, @@ -587,6 +592,7 @@ async fn test_user_ban_requires_pendingban() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1216,6 +1222,8 @@ async fn setup_activated_user() -> (BanksClient, Keypair, Pubkey, Pubkey, Pubkey let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, recent_blockhash, @@ -1239,6 +1247,7 @@ async fn setup_activated_user() -> (BanksClient, Keypair, Pubkey, Pubkey, Pubkey AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/sdk/rs/src/commands/globalconfig/set.rs b/smartcontract/sdk/rs/src/commands/globalconfig/set.rs index bd0885a756..52495758bf 100644 --- a/smartcontract/sdk/rs/src/commands/globalconfig/set.rs +++ b/smartcontract/sdk/rs/src/commands/globalconfig/set.rs @@ -46,6 +46,8 @@ impl SetGlobalConfigCommand { ); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&client.get_program_id(), ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&client.get_program_id(), ResourceType::AdminGroupBits); client.execute_transaction( DoubleZeroInstruction::SetGlobalConfig(set_config_args), @@ -59,6 +61,7 @@ impl SetGlobalConfigCommand { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], ) } From f4d2b4105793f1012d133e18f5ab76d8823814f7 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 13:57:57 -0500 Subject: [PATCH 27/49] cli: remove AdminGroupBits from resource operator commands AdminGroupBits is created automatically by global-config set (not by doublezero resource create), bits are auto-allocated by CreateTopology, and bits are never deallocated per RFC-18. No valid operator CLI workflow exists for any of the create/allocate/deallocate paths. --- smartcontract/cli/src/resource/allocate.rs | 3 +-- smartcontract/cli/src/resource/deallocate.rs | 3 +-- smartcontract/cli/src/resource/mod.rs | 8 -------- 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/smartcontract/cli/src/resource/allocate.rs b/smartcontract/cli/src/resource/allocate.rs index e5c87fcaaf..2e55d0d364 100644 --- a/smartcontract/cli/src/resource/allocate.rs +++ b/smartcontract/cli/src/resource/allocate.rs @@ -40,8 +40,7 @@ impl From for AllocateResourceCommand { | ResourceType::DzPrefixBlock => { IdOrIp::Ip(x.parse::().expect("Failed to parse IP address")) } - ResourceType::AdminGroupBits - | ResourceType::TunnelIds + ResourceType::TunnelIds | ResourceType::LinkIds | ResourceType::SegmentRoutingIds | ResourceType::VrfIds => IdOrIp::Id(x.parse::().expect("Failed to parse ID")), diff --git a/smartcontract/cli/src/resource/deallocate.rs b/smartcontract/cli/src/resource/deallocate.rs index e0af19f55c..21cc73f360 100644 --- a/smartcontract/cli/src/resource/deallocate.rs +++ b/smartcontract/cli/src/resource/deallocate.rs @@ -42,8 +42,7 @@ impl From for DeallocateResourceCommand { .parse::() .expect("Failed to parse IP address"), ), - ResourceType::AdminGroupBits - | ResourceType::TunnelIds + ResourceType::TunnelIds | ResourceType::LinkIds | ResourceType::SegmentRoutingIds | ResourceType::VrfIds => { diff --git a/smartcontract/cli/src/resource/mod.rs b/smartcontract/cli/src/resource/mod.rs index 4b6008c4c6..dfddc61591 100644 --- a/smartcontract/cli/src/resource/mod.rs +++ b/smartcontract/cli/src/resource/mod.rs @@ -11,7 +11,6 @@ pub mod verify; #[derive(Clone, Copy, Debug, ValueEnum)] pub enum ResourceType { - AdminGroupBits, DeviceTunnelBlock, UserTunnelBlock, MulticastGroupBlock, @@ -29,7 +28,6 @@ pub fn resource_type_from( index: Option, ) -> SdkResourceType { match ext { - ResourceType::AdminGroupBits => SdkResourceType::AdminGroupBits, ResourceType::DeviceTunnelBlock => SdkResourceType::DeviceTunnelBlock, ResourceType::UserTunnelBlock => SdkResourceType::UserTunnelBlock, ResourceType::MulticastGroupBlock => SdkResourceType::MulticastGroupBlock, @@ -77,12 +75,6 @@ mod tests { use super::*; use solana_program::pubkey::Pubkey; - #[test] - fn test_admin_group_bits() { - let result = resource_type_from(ResourceType::AdminGroupBits, None, None); - assert_eq!(result, SdkResourceType::AdminGroupBits); - } - #[test] fn test_device_tunnel_block() { let result = resource_type_from(ResourceType::DeviceTunnelBlock, None, None); From 1ad04331b2c82bcf60de3490f6299d2c0c4ca295 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 2 Apr 2026 00:07:36 -0500 Subject: [PATCH 28/49] smartcontract: fix topology/clear missing payer account in SDK command and test --- .../src/processors/topology/clear.rs | 5 ++++- smartcontract/sdk/rs/src/commands/topology/clear.rs | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs index f7c5757f55..6c84f57a31 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs @@ -38,7 +38,10 @@ pub fn process_topology_clear( msg!("process_topology_clear(name={})", value.name); // Payer must be a signer - assert!(payer_account.is_signer, "Payer must be a signer"); + if !payer_account.is_signer { + msg!("TopologyClear: payer must be a signer"); + return Err(DoubleZeroError::Unauthorized.into()); + } // Authorization: foundation keys only let globalstate = GlobalState::try_from(globalstate_account)?; diff --git a/smartcontract/sdk/rs/src/commands/topology/clear.rs b/smartcontract/sdk/rs/src/commands/topology/clear.rs index 6e3c48b971..c2b57595b4 100644 --- a/smartcontract/sdk/rs/src/commands/topology/clear.rs +++ b/smartcontract/sdk/rs/src/commands/topology/clear.rs @@ -19,9 +19,12 @@ impl ClearTopologyCommand { let (topology_pda, _) = get_topology_pda(&client.get_program_id(), &self.name); + let payer = client.get_payer(); + let mut accounts = vec![ AccountMeta::new_readonly(topology_pda, false), AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(payer, true), ]; for link_pk in &self.link_pubkeys { @@ -57,6 +60,7 @@ mod tests { let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "my-topology"); + let payer = client.get_payer(); client .expect_execute_transaction() @@ -67,6 +71,7 @@ mod tests { predicate::eq(vec![ AccountMeta::new_readonly(topology_pda, false), AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(payer, true), ]), ) .returning(|_, _| Ok(Signature::new_unique())); @@ -86,6 +91,7 @@ mod tests { let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "my-topology"); + let payer = client.get_payer(); let link1 = Pubkey::new_unique(); let link2 = Pubkey::new_unique(); @@ -98,6 +104,7 @@ mod tests { predicate::eq(vec![ AccountMeta::new_readonly(topology_pda, false), AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(payer, true), AccountMeta::new(link1, false), AccountMeta::new(link2, false), ]), From bed9396f57695b922b0d39ff3b0b36d8fda50b91 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 2 Apr 2026 00:07:49 -0500 Subject: [PATCH 29/49] smartcontract: add BackfillTopology instruction for post-creation Vpnv4 loopback backfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a standalone BackfillTopology onchain instruction (variant 109) that allocates FlexAlgoNodeSegment entries on existing Vpnv4 loopbacks for an already-created topology. Idempotent — skips loopbacks that already have an entry for this topology. Also fixes a pre-existing bug where CreateIndex/DeleteIndex unpack arms used discriminants 104/105, colliding with CreateTopology/DeleteTopology — corrected to 107/108 per their enum variant comments. Wires up SDK command (BackfillTopologyCommand), CLI command (BackfillTopologyCliCommand under `doublezero link topology backfill`), and rewrites doublezero-admin migrate Part 2 to actually call BackfillTopology instead of just reporting gaps. Integration tests: success + idempotency, non-foundation rejected, nonexistent topology rejected. --- client/doublezero/src/cli/link.rs | 7 +- client/doublezero/src/main.rs | 1 + .../doublezero-admin/src/cli/migrate.rs | 88 +++-- smartcontract/cli/src/doublezerocommand.rs | 9 +- smartcontract/cli/src/topology/backfill.rs | 43 +++ smartcontract/cli/src/topology/mod.rs | 1 + .../src/entrypoint.rs | 7 +- .../src/instructions.rs | 27 +- .../src/processors/topology/backfill.rs | 146 +++++++ .../src/processors/topology/mod.rs | 1 + .../tests/topology_test.rs | 356 +++++++++++++++++- .../sdk/rs/src/commands/topology/backfill.rs | 138 +++++++ .../sdk/rs/src/commands/topology/mod.rs | 1 + 13 files changed, 782 insertions(+), 43 deletions(-) create mode 100644 smartcontract/cli/src/topology/backfill.rs create mode 100644 smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs create mode 100644 smartcontract/sdk/rs/src/commands/topology/backfill.rs diff --git a/client/doublezero/src/cli/link.rs b/client/doublezero/src/cli/link.rs index 8e898db2f4..826547cbb0 100644 --- a/client/doublezero/src/cli/link.rs +++ b/client/doublezero/src/cli/link.rs @@ -6,8 +6,9 @@ use doublezero_cli::{ wan_create::*, }, topology::{ - clear::ClearTopologyCliCommand, create::CreateTopologyCliCommand, - delete::DeleteTopologyCliCommand, list::ListTopologyCliCommand, + backfill::BackfillTopologyCliCommand, clear::ClearTopologyCliCommand, + create::CreateTopologyCliCommand, delete::DeleteTopologyCliCommand, + list::ListTopologyCliCommand, }, }; @@ -78,6 +79,8 @@ pub enum TopologyCommands { Delete(DeleteTopologyCliCommand), /// Clear a topology from links Clear(ClearTopologyCliCommand), + /// Backfill FlexAlgoNodeSegment entries on existing Vpnv4 loopbacks + Backfill(BackfillTopologyCliCommand), /// List all topologies List(ListTopologyCliCommand), } diff --git a/client/doublezero/src/main.rs b/client/doublezero/src/main.rs index d5437e422b..dbc4464886 100644 --- a/client/doublezero/src/main.rs +++ b/client/doublezero/src/main.rs @@ -236,6 +236,7 @@ async fn main() -> eyre::Result<()> { TopologyCommands::Create(args) => args.execute(&client, &mut handle), TopologyCommands::Delete(args) => args.execute(&client, &mut handle), TopologyCommands::Clear(args) => args.execute(&client, &mut handle), + TopologyCommands::Backfill(args) => args.execute(&client, &mut handle), TopologyCommands::List(args) => args.execute(&client, &mut handle), }, }, diff --git a/controlplane/doublezero-admin/src/cli/migrate.rs b/controlplane/doublezero-admin/src/cli/migrate.rs index 57ec0f9b2f..ac0de05cec 100644 --- a/controlplane/doublezero-admin/src/cli/migrate.rs +++ b/controlplane/doublezero-admin/src/cli/migrate.rs @@ -3,11 +3,11 @@ use doublezero_cli::doublezerocommand::CliCommand; use doublezero_sdk::commands::{ device::list::ListDeviceCommand, link::{list::ListLinkCommand, update::UpdateLinkCommand}, - topology::list::ListTopologyCommand, + topology::{backfill::BackfillTopologyCommand, list::ListTopologyCommand}, }; use doublezero_serviceability::{pda::get_topology_pda, state::interface::LoopbackType}; use solana_sdk::pubkey::Pubkey; -use std::{collections::HashSet, io::Write}; +use std::io::Write; #[derive(Args, Debug)] pub struct MigrateCliCommand { @@ -77,38 +77,69 @@ impl MigrateCliCommand { } } - // ── Part 2: Vpnv4 loopback gap reporting ───────────────────────────────── + // ── Part 2: Vpnv4 loopback FlexAlgoNodeSegment backfill ───────────────── let topologies = client.list_topology(ListTopologyCommand)?; - let topology_pubkeys: HashSet = topologies.keys().copied().collect(); + let mut topology_entries: Vec<(Pubkey, _)> = topologies.into_iter().collect(); + topology_entries.sort_by_key(|(pk, _)| pk.to_string()); let devices = client.list_device(ListDeviceCommand)?; - let mut loopbacks_with_gaps = 0u32; - let mut device_entries: Vec<(Pubkey, _)> = devices.into_iter().collect(); device_entries.sort_by_key(|(pk, _)| pk.to_string()); - for (device_pubkey, device) in &device_entries { - for iface in &device.interfaces { - let current = iface.into_current_version(); - if current.loopback_type != LoopbackType::Vpnv4 { - continue; + // For each topology, find devices that have Vpnv4 loopbacks missing that + // topology's segment — then backfill in a single transaction per topology. + let mut topologies_backfilled = 0u32; + let mut topologies_skipped = 0u32; + + for (topology_pubkey, topology) in &topology_entries { + // Collect devices that have at least one Vpnv4 loopback missing this topology + let mut devices_needing_backfill: Vec = Vec::new(); + + for (device_pubkey, device) in &device_entries { + let needs_backfill = device.interfaces.iter().any(|iface| { + let current = iface.into_current_version(); + current.loopback_type == LoopbackType::Vpnv4 + && !current + .flex_algo_node_segments + .iter() + .any(|s| s.topology == *topology_pubkey) + }); + if needs_backfill { + devices_needing_backfill.push(*device_pubkey); } + } + + if devices_needing_backfill.is_empty() { + topologies_skipped += 1; + continue; + } - let present: HashSet = current - .flex_algo_node_segments - .iter() - .map(|seg| seg.topology) - .collect(); - - let missing_count = topology_pubkeys.difference(&present).count(); - if missing_count > 0 { - loopbacks_with_gaps += 1; - writeln!( - out, - " [loopback] {device_pubkey} iface={} — missing {missing_count} topology entries; re-create topology with device accounts to backfill", - current.name - )?; + topologies_backfilled += 1; + writeln!( + out, + " [topology] {} ({}) — {} device(s) need backfill", + topology.name, + topology_pubkey, + devices_needing_backfill.len() + )?; + + if !self.dry_run { + let result = client.backfill_topology(BackfillTopologyCommand { + name: topology.name.clone(), + device_pubkeys: devices_needing_backfill, + }); + match result { + Ok(sig) => { + writeln!(out, " backfilled: {sig}")?; + } + Err(e) => { + writeln!( + out, + " WARNING: failed to backfill topology {}: {e}", + topology.name + )?; + } } } } @@ -125,9 +156,14 @@ impl MigrateCliCommand { } else { format!("{links_tagged} link(s) tagged") }; + let loopback_summary = if self.dry_run { + format!("{topologies_backfilled} topology(s) would be backfilled") + } else { + format!("{topologies_backfilled} topology(s) backfilled") + }; writeln!( out, - "\nMigration complete: {tagged_summary}, {links_skipped} link(s) skipped, {loopbacks_with_gaps} loopback(s) with gaps{dry_run_suffix}" + "\nMigration complete: {tagged_summary}, {links_skipped} link(s) skipped; {loopback_summary}, {topologies_skipped} topology(s) already complete{dry_run_suffix}" )?; Ok(()) diff --git a/smartcontract/cli/src/doublezerocommand.rs b/smartcontract/cli/src/doublezerocommand.rs index 1b1eab3126..873260bd0c 100644 --- a/smartcontract/cli/src/doublezerocommand.rs +++ b/smartcontract/cli/src/doublezerocommand.rs @@ -100,8 +100,9 @@ use doublezero_sdk::{ update_payment_status::UpdatePaymentStatusCommand, }, topology::{ - clear::ClearTopologyCommand, create::CreateTopologyCommand, - delete::DeleteTopologyCommand, list::ListTopologyCommand, + backfill::BackfillTopologyCommand, clear::ClearTopologyCommand, + create::CreateTopologyCommand, delete::DeleteTopologyCommand, + list::ListTopologyCommand, }, user::{ create::CreateUserCommand, create_subscribe::CreateSubscribeUserCommand, @@ -345,6 +346,7 @@ pub trait CliCommand { fn create_topology(&self, cmd: CreateTopologyCommand) -> eyre::Result<(Signature, Pubkey)>; fn delete_topology(&self, cmd: DeleteTopologyCommand) -> eyre::Result; fn clear_topology(&self, cmd: ClearTopologyCommand) -> eyre::Result; + fn backfill_topology(&self, cmd: BackfillTopologyCommand) -> eyre::Result; fn list_topology( &self, cmd: ListTopologyCommand, @@ -821,6 +823,9 @@ impl CliCommand for CliCommandImpl<'_> { fn clear_topology(&self, cmd: ClearTopologyCommand) -> eyre::Result { cmd.execute(self.client) } + fn backfill_topology(&self, cmd: BackfillTopologyCommand) -> eyre::Result { + cmd.execute(self.client) + } fn list_topology( &self, cmd: ListTopologyCommand, diff --git a/smartcontract/cli/src/topology/backfill.rs b/smartcontract/cli/src/topology/backfill.rs new file mode 100644 index 0000000000..6bb31a1ac0 --- /dev/null +++ b/smartcontract/cli/src/topology/backfill.rs @@ -0,0 +1,43 @@ +use crate::{ + doublezerocommand::CliCommand, + requirements::{CHECK_BALANCE, CHECK_ID_JSON}, +}; +use clap::Args; +use doublezero_sdk::commands::topology::backfill::BackfillTopologyCommand; +use solana_sdk::pubkey::Pubkey; +use std::io::Write; + +#[derive(Args, Debug)] +pub struct BackfillTopologyCliCommand { + /// Name of the topology to backfill + #[arg(long)] + pub name: String, + /// Device account pubkeys to backfill (one or more) + #[arg(long = "device", value_name = "PUBKEY")] + pub device_pubkeys: Vec, +} + +impl BackfillTopologyCliCommand { + pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { + client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; + + if self.device_pubkeys.is_empty() { + return Err(eyre::eyre!( + "at least one --device pubkey is required for backfill" + )); + } + + let sig = client.backfill_topology(BackfillTopologyCommand { + name: self.name.clone(), + device_pubkeys: self.device_pubkeys, + })?; + + writeln!( + out, + "Backfilled topology '{}'. Signature: {}", + self.name, sig + )?; + + Ok(()) + } +} diff --git a/smartcontract/cli/src/topology/mod.rs b/smartcontract/cli/src/topology/mod.rs index 9c8c1e08a5..01fa6fd760 100644 --- a/smartcontract/cli/src/topology/mod.rs +++ b/smartcontract/cli/src/topology/mod.rs @@ -1,3 +1,4 @@ +pub mod backfill; pub mod clear; pub mod create; pub mod delete; diff --git a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs index ef4313121b..cabda068c2 100644 --- a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs +++ b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs @@ -97,8 +97,8 @@ use crate::{ update::process_update_tenant, update_payment_status::process_update_payment_status, }, topology::{ - clear::process_topology_clear, create::process_topology_create, - delete::process_topology_delete, + backfill::process_topology_backfill, clear::process_topology_clear, + create::process_topology_create, delete::process_topology_delete, }, user::{ activate::process_activate_user, ban::process_ban_user, @@ -434,6 +434,9 @@ pub fn process_instruction( DoubleZeroInstruction::ClearTopology(value) => { process_topology_clear(program_id, accounts, &value)? } + DoubleZeroInstruction::BackfillTopology(value) => { + process_topology_backfill(program_id, accounts, &value)? + } }; Ok(()) } diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index 5959946b09..75397ef969 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -79,7 +79,10 @@ use crate::processors::{ delete::TenantDeleteArgs, remove_administrator::TenantRemoveAdministratorArgs, update::TenantUpdateArgs, update_payment_status::UpdatePaymentStatusArgs, }, - topology::{clear::TopologyClearArgs, create::TopologyCreateArgs, delete::TopologyDeleteArgs}, + topology::{ + backfill::TopologyBackfillArgs, clear::TopologyClearArgs, create::TopologyCreateArgs, + delete::TopologyDeleteArgs, + }, user::{ activate::UserActivateArgs, ban::UserBanArgs, check_access_pass::CheckUserAccessPassArgs, closeaccount::UserCloseAccountArgs, create::UserCreateArgs, @@ -220,9 +223,10 @@ pub enum DoubleZeroInstruction { Deprecated102(), // variant 102 (was CreateReservedSubscribeUser) Deprecated103(), // variant 103 (was DeleteReservedSubscribeUser) - CreateTopology(TopologyCreateArgs), // variant 104 - DeleteTopology(TopologyDeleteArgs), // variant 105 - ClearTopology(TopologyClearArgs), // variant 106 + CreateTopology(TopologyCreateArgs), // variant 104 + DeleteTopology(TopologyDeleteArgs), // variant 105 + ClearTopology(TopologyClearArgs), // variant 106 + BackfillTopology(TopologyBackfillArgs), // variant 107 } impl DoubleZeroInstruction { @@ -357,6 +361,7 @@ impl DoubleZeroInstruction { 104 => Ok(Self::CreateTopology(TopologyCreateArgs::try_from(rest).unwrap())), 105 => Ok(Self::DeleteTopology(TopologyDeleteArgs::try_from(rest).unwrap())), 106 => Ok(Self::ClearTopology(TopologyClearArgs::try_from(rest).unwrap())), + 107 => Ok(Self::BackfillTopology(TopologyBackfillArgs::try_from(rest).unwrap())), _ => Err(ProgramError::InvalidInstructionData), } @@ -492,9 +497,10 @@ impl DoubleZeroInstruction { Self::Deprecated102() => "Deprecated102".to_string(), Self::Deprecated103() => "Deprecated103".to_string(), - Self::CreateTopology(_) => "CreateTopology".to_string(), // variant 104 - Self::DeleteTopology(_) => "DeleteTopology".to_string(), // variant 105 - Self::ClearTopology(_) => "ClearTopology".to_string(), // variant 106 + Self::CreateTopology(_) => "CreateTopology".to_string(), // variant 104 + Self::DeleteTopology(_) => "DeleteTopology".to_string(), // variant 105 + Self::ClearTopology(_) => "ClearTopology".to_string(), // variant 106 + Self::BackfillTopology(_) => "BackfillTopology".to_string(), // variant 107 } } @@ -622,9 +628,10 @@ impl DoubleZeroInstruction { Self::Deprecated102() => String::new(), Self::Deprecated103() => String::new(), - Self::CreateTopology(args) => format!("{args:?}"), // variant 104 - Self::DeleteTopology(args) => format!("{args:?}"), // variant 105 - Self::ClearTopology(args) => format!("{args:?}"), // variant 106 + Self::CreateTopology(args) => format!("{args:?}"), // variant 104 + Self::DeleteTopology(args) => format!("{args:?}"), // variant 105 + Self::ClearTopology(args) => format!("{args:?}"), // variant 106 + Self::BackfillTopology(args) => format!("{args:?}"), // variant 107 } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs new file mode 100644 index 0000000000..33452554b6 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs @@ -0,0 +1,146 @@ +use crate::{ + error::DoubleZeroError, + pda::{get_resource_extension_pda, get_topology_pda}, + processors::resource::allocate_id, + resource::ResourceType, + serializer::try_acc_write, + state::{ + device::Device, + globalstate::GlobalState, + interface::{Interface, LoopbackType}, + topology::FlexAlgoNodeSegment, + }, +}; +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + pubkey::Pubkey, +}; + +#[derive(BorshSerialize, BorshDeserializeIncremental, Debug, Clone, PartialEq)] +pub struct TopologyBackfillArgs { + pub name: String, +} + +/// Backfill FlexAlgoNodeSegment entries on existing Vpnv4 loopbacks for an +/// already-created topology. Idempotent — skips loopbacks that already have +/// an entry for this topology. +/// +/// Accounts layout: +/// [0] topology PDA (readonly — must already exist) +/// [1] segment_routing_ids (writable, ResourceExtension) +/// [2] globalstate (readonly) +/// [3] payer (writable, signer, must be in foundation_allowlist) +/// [4+] Device accounts (writable) +pub fn process_topology_backfill( + program_id: &Pubkey, + accounts: &[AccountInfo], + value: &TopologyBackfillArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let topology_account = next_account_info(accounts_iter)?; + let segment_routing_ids_account = next_account_info(accounts_iter)?; + let globalstate_account = next_account_info(accounts_iter)?; + let payer_account = next_account_info(accounts_iter)?; + + #[cfg(test)] + msg!("process_topology_backfill(name={})", value.name); + + if !payer_account.is_signer { + msg!("TopologyBackfill: payer must be a signer"); + return Err(DoubleZeroError::Unauthorized.into()); + } + + let globalstate = GlobalState::try_from(globalstate_account)?; + if !globalstate.foundation_allowlist.contains(payer_account.key) { + msg!("TopologyBackfill: unauthorized — foundation key required"); + return Err(DoubleZeroError::Unauthorized.into()); + } + + // Validate topology PDA + let (expected_pda, _) = get_topology_pda(program_id, &value.name); + if topology_account.key != &expected_pda { + msg!( + "TopologyBackfill: invalid topology PDA for name '{}'", + value.name + ); + return Err(DoubleZeroError::InvalidArgument.into()); + } + + if topology_account.data_is_empty() { + msg!("TopologyBackfill: topology '{}' does not exist", value.name); + return Err(DoubleZeroError::InvalidArgument.into()); + } + + // Validate SegmentRoutingIds account + let (expected_sr_pda, _, _) = + get_resource_extension_pda(program_id, ResourceType::SegmentRoutingIds); + if segment_routing_ids_account.key != &expected_sr_pda { + msg!("TopologyBackfill: invalid SegmentRoutingIds PDA"); + return Err(DoubleZeroError::InvalidArgument.into()); + } + + let topology_key = topology_account.key; + let mut backfilled_count: usize = 0; + let mut skipped_count: usize = 0; + + for device_account in accounts_iter { + if device_account.owner != program_id { + continue; + } + let mut device = match Device::try_from(&device_account.data.borrow()[..]) { + Ok(d) => d, + Err(_) => continue, + }; + let mut modified = false; + for iface in device.interfaces.iter_mut() { + let iface_v3 = iface.into_current_version(); + if iface_v3.loopback_type != LoopbackType::Vpnv4 { + continue; + } + // Skip if already has a segment for this topology (idempotent) + if iface_v3 + .flex_algo_node_segments + .iter() + .any(|s| &s.topology == topology_key) + { + skipped_count += 1; + continue; + } + let node_segment_idx = allocate_id(segment_routing_ids_account)?; + match iface { + Interface::V3(ref mut v3) => { + v3.flex_algo_node_segments.push(FlexAlgoNodeSegment { + topology: *topology_key, + node_segment_idx, + }); + } + _ => { + let mut upgraded = iface.into_current_version(); + upgraded.flex_algo_node_segments.push(FlexAlgoNodeSegment { + topology: *topology_key, + node_segment_idx, + }); + *iface = Interface::V3(upgraded); + } + } + modified = true; + backfilled_count += 1; + } + if modified { + try_acc_write(&device, device_account, payer_account, accounts)?; + } + } + + msg!( + "TopologyBackfill: '{}' — {} loopback(s) backfilled, {} already had segment", + value.name, + backfilled_count, + skipped_count + ); + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs index 52a0eb0975..b45b525a64 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs @@ -1,3 +1,4 @@ +pub mod backfill; pub mod clear; pub mod create; pub mod delete; diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index aa0fc443d7..4c6a69d13e 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -20,7 +20,8 @@ use doublezero_serviceability::{ link::{activate::LinkActivateArgs, create::LinkCreateArgs, update::LinkUpdateArgs}, location::create::LocationCreateArgs, topology::{ - clear::TopologyClearArgs, create::TopologyCreateArgs, delete::TopologyDeleteArgs, + backfill::TopologyBackfillArgs, clear::TopologyClearArgs, create::TopologyCreateArgs, + delete::TopologyDeleteArgs, }, }, resource::{IdOrIp, ResourceType}, @@ -1536,6 +1537,359 @@ async fn test_topology_clear_non_foundation_rejected() { println!("[PASS] test_topology_clear_non_foundation_rejected"); } +// ============================================================================ +// BackfillTopology tests +// ============================================================================ + +#[tokio::test] +async fn test_topology_backfill_populates_vpnv4_loopbacks() { + println!("[TEST] test_topology_backfill_populates_vpnv4_loopbacks"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); + + // Step 1: Create Location + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (location_pubkey, _) = get_location_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateLocation(LocationCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + country: "us".to_string(), + lat: 1.234, + lng: 4.567, + loc_id: 0, + }), + vec![ + AccountMeta::new(location_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 2: Create Exchange + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (exchange_pubkey, _) = get_exchange_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateExchange(ExchangeCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + lat: 1.234, + lng: 4.567, + reserved: 0, + }), + vec![ + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 3: Create Contributor + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (contributor_pubkey, _) = + get_contributor_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateContributor(ContributorCreateArgs { + code: "cont".to_string(), + }), + vec![ + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(payer.pubkey(), false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 4: Create Device + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (device_pubkey, _) = get_device_pda(&program_id, globalstate_account.account_index + 1); + let (tunnel_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::TunnelIds(device_pubkey, 0)); + let (dz_prefix_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::DzPrefixBlock(device_pubkey, 0)); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDevice(DeviceCreateArgs { + code: "dz1".to_string(), + device_type: DeviceType::Hybrid, + public_ip: [8, 8, 8, 8].into(), + dz_prefixes: "110.1.0.0/23".parse().unwrap(), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "mgmt".to_string(), + desired_status: Some(DeviceDesiredStatus::Activated), + resource_count: 0, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 5: Activate Device + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateDevice(DeviceActivateArgs { resource_count: 2 }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(tunnel_ids_pda, false), + AccountMeta::new(dz_prefix_pda, false), + ], + &payer, + ) + .await; + + // Step 6: Create a Vpnv4 loopback interface + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDeviceInterface(DeviceInterfaceCreateArgs { + name: "loopback0".to_string(), + interface_dia: InterfaceDIA::None, + loopback_type: LoopbackType::Vpnv4, + interface_cyoa: InterfaceCYOA::None, + bandwidth: 0, + cir: 0, + ip_net: None, + mtu: 1500, + routing_mode: RoutingMode::Static, + vlan_id: 0, + user_tunnel_endpoint: false, + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 7: Create topology WITHOUT passing device accounts — no backfill at create time + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + // Verify: device has 0 flex_algo_node_segments before backfill + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found"); + let iface = device.interfaces[0].into_current_version(); + assert_eq!( + iface.flex_algo_node_segments.len(), + 0, + "Expected no segments before BackfillTopology" + ); + + // Step 8: Call BackfillTopology instruction + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + let base_accounts = vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]; + let extra_accounts = vec![AccountMeta::new(device_pubkey, false)]; + let mut tx = create_transaction_with_extra_accounts( + program_id, + &DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + name: "unicast-default".to_string(), + }), + &base_accounts, + &payer, + &extra_accounts, + ); + tx.try_sign(&[&payer], recent_blockhash).unwrap(); + banks_client.process_transaction(tx).await.unwrap(); + + // Verify: loopback now has 1 segment pointing to the topology + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found after backfill"); + let iface = device.interfaces[0].into_current_version(); + assert_eq!( + iface.flex_algo_node_segments.len(), + 1, + "Expected one flex_algo_node_segment after BackfillTopology" + ); + assert_eq!( + iface.flex_algo_node_segments[0].topology, topology_pda, + "Segment should point to the backfilled topology" + ); + + // Step 9: Call BackfillTopology again — idempotent, no duplicate segment + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + let mut tx2 = create_transaction_with_extra_accounts( + program_id, + &DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + name: "unicast-default".to_string(), + }), + &base_accounts, + &payer, + &extra_accounts, + ); + tx2.try_sign(&[&payer], recent_blockhash).unwrap(); + banks_client.process_transaction(tx2).await.unwrap(); + + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found after second backfill"); + let iface = device.interfaces[0].into_current_version(); + assert_eq!( + iface.flex_algo_node_segments.len(), + 1, + "Idempotent: BackfillTopology must not add a duplicate segment" + ); + + println!("[PASS] test_topology_backfill_populates_vpnv4_loopbacks"); +} + +#[tokio::test] +async fn test_topology_backfill_non_foundation_rejected() { + println!("[TEST] test_topology_backfill_non_foundation_rejected"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); + + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + let non_foundation = Keypair::new(); + transfer( + &mut banks_client, + &payer, + &non_foundation.pubkey(), + 10_000_000, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + name: "unicast-default".to_string(), + }), + vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &non_foundation, + ) + .await; + + // DoubleZeroError::Unauthorized = Custom(22) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(22), + ))) => {} + _ => panic!("Expected Unauthorized error (Custom(22)), got {:?}", result), + } + + println!("[PASS] test_topology_backfill_non_foundation_rejected"); +} + +#[tokio::test] +async fn test_topology_backfill_nonexistent_topology_rejected() { + println!("[TEST] test_topology_backfill_nonexistent_topology_rejected"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); + + // Use a topology PDA that has never been created + let (nonexistent_topology_pda, _) = get_topology_pda(&program_id, "does-not-exist"); + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + name: "does-not-exist".to_string(), + }), + vec![ + AccountMeta::new_readonly(nonexistent_topology_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // DoubleZeroError::InvalidArgument = Custom(65) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(65), + ))) => {} + _ => panic!( + "Expected InvalidArgument error (Custom(65)), got {:?}", + result + ), + } + + println!("[PASS] test_topology_backfill_nonexistent_topology_rejected"); +} + // ============================================================================ // unicast_drained tests // ============================================================================ diff --git a/smartcontract/sdk/rs/src/commands/topology/backfill.rs b/smartcontract/sdk/rs/src/commands/topology/backfill.rs new file mode 100644 index 0000000000..7342ba2cc3 --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/topology/backfill.rs @@ -0,0 +1,138 @@ +use crate::{commands::globalstate::get::GetGlobalStateCommand, DoubleZeroClient}; +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_resource_extension_pda, get_topology_pda}, + processors::topology::backfill::TopologyBackfillArgs, + resource::ResourceType, +}; +use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + +#[derive(Debug, PartialEq, Clone)] +pub struct BackfillTopologyCommand { + pub name: String, + pub device_pubkeys: Vec, +} + +impl BackfillTopologyCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result { + let (globalstate_pubkey, _globalstate) = GetGlobalStateCommand + .execute(client) + .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; + + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), &self.name); + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&client.get_program_id(), ResourceType::SegmentRoutingIds); + + let payer = client.get_payer(); + + let mut accounts = vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(payer, true), + ]; + + for device_pk in &self.device_pubkeys { + accounts.push(AccountMeta::new(*device_pk, false)); + } + + client.execute_transaction( + DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + name: self.name.clone(), + }), + accounts, + ) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + commands::topology::backfill::BackfillTopologyCommand, tests::utils::create_test_client, + DoubleZeroClient, + }; + use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_globalstate_pda, get_resource_extension_pda, get_topology_pda}, + processors::topology::backfill::TopologyBackfillArgs, + resource::ResourceType, + }; + use mockall::predicate; + use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + + #[test] + fn test_commands_topology_backfill_no_devices() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "unicast-default"); + let (sr_ids_pda, _, _) = + get_resource_extension_pda(&client.get_program_id(), ResourceType::SegmentRoutingIds); + let payer = client.get_payer(); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::BackfillTopology( + TopologyBackfillArgs { + name: "unicast-default".to_string(), + }, + )), + predicate::eq(vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new(sr_ids_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(payer, true), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = BackfillTopologyCommand { + name: "unicast-default".to_string(), + device_pubkeys: vec![], + } + .execute(&client); + + assert!(res.is_ok()); + } + + #[test] + fn test_commands_topology_backfill_with_devices() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "algo128"); + let (sr_ids_pda, _, _) = + get_resource_extension_pda(&client.get_program_id(), ResourceType::SegmentRoutingIds); + let payer = client.get_payer(); + let device1 = Pubkey::new_unique(); + let device2 = Pubkey::new_unique(); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::BackfillTopology( + TopologyBackfillArgs { + name: "algo128".to_string(), + }, + )), + predicate::eq(vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new(sr_ids_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(payer, true), + AccountMeta::new(device1, false), + AccountMeta::new(device2, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = BackfillTopologyCommand { + name: "algo128".to_string(), + device_pubkeys: vec![device1, device2], + } + .execute(&client); + + assert!(res.is_ok()); + } +} diff --git a/smartcontract/sdk/rs/src/commands/topology/mod.rs b/smartcontract/sdk/rs/src/commands/topology/mod.rs index 9c8c1e08a5..01fa6fd760 100644 --- a/smartcontract/sdk/rs/src/commands/topology/mod.rs +++ b/smartcontract/sdk/rs/src/commands/topology/mod.rs @@ -1,3 +1,4 @@ +pub mod backfill; pub mod clear; pub mod create; pub mod delete; From b5a049dd6b008d13cfbe47e75e319d55fd4c5100 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 2 Apr 2026 00:53:45 -0500 Subject: [PATCH 30/49] cli: validate topology name length before PDA derivation --- smartcontract/cli/src/topology/create.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/smartcontract/cli/src/topology/create.rs b/smartcontract/cli/src/topology/create.rs index b89b7af449..febdaeedc0 100644 --- a/smartcontract/cli/src/topology/create.rs +++ b/smartcontract/cli/src/topology/create.rs @@ -30,6 +30,10 @@ fn parse_constraint(s: &str) -> Result { impl CreateTopologyCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { + if self.name.len() > 32 { + eyre::bail!("topology name must be 32 characters or fewer (got {})", self.name.len()); + } + client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; let (_, topology_pda) = client.create_topology(CreateTopologyCommand { @@ -97,4 +101,17 @@ mod tests { fn test_parse_constraint_invalid() { assert!(parse_constraint("unknown").is_err()); } + + #[test] + fn test_create_topology_name_too_long() { + let cmd = CreateTopologyCliCommand { + name: "a".repeat(33), + constraint: TopologyConstraint::IncludeAny, + }; + let mock = MockCliCommand::new(); + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("32 characters or fewer")); + } } From 27583eeeba773cca47101d5333fa504407d0ebfa Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 2 Apr 2026 08:30:58 -0500 Subject: [PATCH 31/49] e2e: add doublezero-admin to manager container --- e2e/docker/manager/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/docker/manager/Dockerfile b/e2e/docker/manager/Dockerfile index a304ec5d9f..89c89cf5e6 100644 --- a/e2e/docker/manager/Dockerfile +++ b/e2e/docker/manager/Dockerfile @@ -13,6 +13,7 @@ COPY --from=base /doublezero/bin/doublezero_serviceability.so /doublezero/bin/. COPY --from=base /doublezero/bin/doublezero_telemetry.so /doublezero/bin/. COPY --from=base /doublezero/bin/doublezero_geolocation.so /doublezero/bin/. COPY --from=base /doublezero/bin/doublezero-geolocation /doublezero/bin/. +COPY --from=base /doublezero/bin/doublezero-admin /doublezero/bin/. COPY --from=base /usr/local/bin/solana /usr/local/bin/. COPY --from=base /usr/local/bin/solana-keygen /usr/local/bin/. From 3a9535bd723bfdc07b9203aaeb67c6467e9fe5de Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 2 Apr 2026 08:45:37 -0500 Subject: [PATCH 32/49] sdk: regenerate fixtures with RFC-18 fields; enable flex_algo_node_segments reading - link fixture: add link_topologies (1 entry) and unicast_drained=true - tenant fixture: add include_topologies (1 entry) - new topology_info fixture for TopologyInfo account (account_type=16) - Python/TypeScript Interface: add V3 deserialization with flex_algo_node_segments; bump CURRENT_INTERFACE_VERSION to 3 --- .../python/serviceability/state.py | 23 ++++++++- .../fixtures/generate-fixtures/src/main.rs | 46 ++++++++++++++++-- sdk/serviceability/testdata/fixtures/link.bin | Bin 230 -> 267 bytes .../testdata/fixtures/link.json | 15 ++++++ .../testdata/fixtures/tenant.bin | Bin 136 -> 172 bytes .../testdata/fixtures/tenant.json | 10 ++++ .../testdata/fixtures/topology_info.bin | Bin 0 -> 56 bytes .../testdata/fixtures/topology_info.json | 41 ++++++++++++++++ sdk/serviceability/testdata/fixtures/user.bin | Bin 240 -> 241 bytes .../typescript/serviceability/state.ts | 27 +++++++++- 10 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 sdk/serviceability/testdata/fixtures/topology_info.bin create mode 100644 sdk/serviceability/testdata/fixtures/topology_info.json diff --git a/sdk/serviceability/python/serviceability/state.py b/sdk/serviceability/python/serviceability/state.py index 705799db5f..f9942699af 100644 --- a/sdk/serviceability/python/serviceability/state.py +++ b/sdk/serviceability/python/serviceability/state.py @@ -390,7 +390,7 @@ def __str__(self) -> str: # Account dataclasses # --------------------------------------------------------------------------- -CURRENT_INTERFACE_VERSION = 2 +CURRENT_INTERFACE_VERSION = 3 @dataclass @@ -441,6 +441,27 @@ def from_reader(cls, r: IncrementalReader) -> Interface: iface.ip_net = r.read_network_v4() iface.node_segment_idx = r.read_u16() iface.user_tunnel_endpoint = r.read_bool() + elif iface.version == 2: # V3 + iface.status = InterfaceStatus(r.read_u8()) + iface.name = r.read_string() + iface.interface_type = InterfaceType(r.read_u8()) + iface.interface_cyoa = InterfaceCYOA(r.read_u8()) + iface.interface_dia = InterfaceDIA(r.read_u8()) + iface.loopback_type = LoopbackType(r.read_u8()) + iface.bandwidth = r.read_u64() + iface.cir = r.read_u64() + iface.mtu = r.read_u16() + iface.routing_mode = RoutingMode(r.read_u8()) + iface.vlan_id = r.read_u16() + iface.ip_net = r.read_network_v4() + iface.node_segment_idx = r.read_u16() + iface.user_tunnel_endpoint = r.read_bool() + count = r.read_u32() + for _ in range(count): + seg = FlexAlgoNodeSegment() + seg.topology = _read_pubkey(r) + seg.node_segment_idx = r.read_u16() + iface.flex_algo_node_segments.append(seg) return iface diff --git a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs index be3d969d5f..8fa2494214 100644 --- a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs +++ b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs @@ -87,6 +87,7 @@ fn main() { generate_access_pass(&fixtures_dir); generate_access_pass_validator(&fixtures_dir); generate_tenant(&fixtures_dir); + generate_topology(&fixtures_dir); generate_resource_extension_id(&fixtures_dir); generate_resource_extension_ip(&fixtures_dir); @@ -410,6 +411,7 @@ fn generate_link(dir: &Path) { let side_a_pk = pubkey_from_byte(0x51); let side_z_pk = pubkey_from_byte(0x52); let contributor_pk = pubkey_from_byte(0x53); + let topology_pk = pubkey_from_byte(0x54); let val = Link { account_type: AccountType::Link, @@ -433,8 +435,8 @@ fn generate_link(dir: &Path) { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, - link_topologies: Vec::new(), - unicast_drained: false, + link_topologies: vec![topology_pk], + unicast_drained: true, }; let data = borsh::to_vec(&val).unwrap(); @@ -464,6 +466,9 @@ fn generate_link(dir: &Path) { FieldValue { name: "DelayOverrideNs".into(), value: "0".into(), typ: "u64".into() }, FieldValue { name: "LinkHealth".into(), value: "2".into(), typ: "u8".into() }, FieldValue { name: "DesiredStatus".into(), value: "1".into(), typ: "u8".into() }, + FieldValue { name: "LinkTopologiesLen".into(), value: "1".into(), typ: "u32".into() }, + FieldValue { name: "LinkTopologies0".into(), value: pubkey_bs58(&topology_pk), typ: "pubkey".into() }, + FieldValue { name: "UnicastDrained".into(), value: "true".into(), typ: "bool".into() }, ], }; @@ -740,6 +745,7 @@ fn generate_tenant(dir: &Path) { let owner = pubkey_from_byte(0xD0); let admin_pk = pubkey_from_byte(0xD1); let token_account = pubkey_from_byte(0xD2); + let topology_pk = pubkey_from_byte(0xD3); let val = Tenant { account_type: AccountType::Tenant, @@ -754,7 +760,7 @@ fn generate_tenant(dir: &Path) { metro_routing: true, route_liveness: false, billing: TenantBillingConfig::default(), - include_topologies: vec![], + include_topologies: vec![topology_pk], }; let data = borsh::to_vec(&val).unwrap(); @@ -778,12 +784,46 @@ fn generate_tenant(dir: &Path) { FieldValue { name: "BillingDiscriminant".into(), value: "0".into(), typ: "u8".into() }, FieldValue { name: "BillingRate".into(), value: "0".into(), typ: "u64".into() }, FieldValue { name: "BillingLastDeductionDzEpoch".into(), value: "0".into(), typ: "u64".into() }, + FieldValue { name: "IncludeTopologiesLen".into(), value: "1".into(), typ: "u32".into() }, + FieldValue { name: "IncludeTopologies0".into(), value: pubkey_bs58(&topology_pk), typ: "pubkey".into() }, ], }; write_fixture(dir, "tenant", &data, &meta); } +fn generate_topology(dir: &Path) { + let owner = pubkey_from_byte(0xE0); + + let val = doublezero_serviceability::state::topology::TopologyInfo { + account_type: AccountType::Topology, + owner, + bump_seed: 250, + name: "unicast-default".into(), + admin_group_bit: 0, + flex_algo_number: 128, + constraint: doublezero_serviceability::state::topology::TopologyConstraint::IncludeAny, + }; + + let data = borsh::to_vec(&val).unwrap(); + + let meta = FixtureMeta { + name: "TopologyInfo".into(), + account_type: 16, + fields: vec![ + FieldValue { name: "AccountType".into(), value: "16".into(), typ: "u8".into() }, + FieldValue { name: "Owner".into(), value: pubkey_bs58(&owner), typ: "pubkey".into() }, + FieldValue { name: "BumpSeed".into(), value: "250".into(), typ: "u8".into() }, + FieldValue { name: "Name".into(), value: "unicast-default".into(), typ: "string".into() }, + FieldValue { name: "AdminGroupBit".into(), value: "0".into(), typ: "u8".into() }, + FieldValue { name: "FlexAlgoNumber".into(), value: "128".into(), typ: "u8".into() }, + FieldValue { name: "Constraint".into(), value: "0".into(), typ: "u8".into() }, + ], + }; + + write_fixture(dir, "topology_info", &data, &meta); +} + /// ResourceExtension uses a fixed binary layout with bitmap at offset 88, /// so we manually construct the bytes rather than using borsh::to_vec. const RESOURCE_EXTENSION_BITMAP_OFFSET: usize = 88; diff --git a/sdk/serviceability/testdata/fixtures/link.bin b/sdk/serviceability/testdata/fixtures/link.bin index e988d5d648ce75f60f80587f4d43b35575db748e..3390047b45962a9eb8dded18d1b435ee3c367454 100644 GIT binary patch delta 44 XcmaFH*v&NInJOa#149T9;{%KUi2(y< delta 6 NcmeBXdd4{682||L0_y+( diff --git a/sdk/serviceability/testdata/fixtures/link.json b/sdk/serviceability/testdata/fixtures/link.json index b1f786b209..b5ca99ef35 100644 --- a/sdk/serviceability/testdata/fixtures/link.json +++ b/sdk/serviceability/testdata/fixtures/link.json @@ -106,6 +106,21 @@ "name": "DesiredStatus", "value": "1", "typ": "u8" + }, + { + "name": "LinkTopologiesLen", + "value": "1", + "typ": "u32" + }, + { + "name": "LinkTopologies0", + "value": "6euFJbx65EayK76qjPNWCqcCjpTkJoZA3rubHP4bYaXy", + "typ": "pubkey" + }, + { + "name": "UnicastDrained", + "value": "true", + "typ": "bool" } ] } \ No newline at end of file diff --git a/sdk/serviceability/testdata/fixtures/tenant.bin b/sdk/serviceability/testdata/fixtures/tenant.bin index bfc41f6d8c32392e0110bb8b23c4e0b9baa30e5e..0748273f3c3b1303a66bdad6b116bdc7868dadad 100644 GIT binary patch delta 23 dcmeBRT*Ek_gO!nif#LGRM3IRFtU$!b002-k20H)% delta 6 NcmZ3(*uglV0{{o!0#5({ diff --git a/sdk/serviceability/testdata/fixtures/tenant.json b/sdk/serviceability/testdata/fixtures/tenant.json index 83ea1f76b8..a8fdcae48e 100644 --- a/sdk/serviceability/testdata/fixtures/tenant.json +++ b/sdk/serviceability/testdata/fixtures/tenant.json @@ -76,6 +76,16 @@ "name": "BillingLastDeductionDzEpoch", "value": "0", "typ": "u64" + }, + { + "name": "IncludeTopologiesLen", + "value": "1", + "typ": "u32" + }, + { + "name": "IncludeTopologies0", + "value": "FCf2QW6oaRTeBxNaLUCCuXGEc4DdyTVJD25ND9Tz5kVm", + "typ": "pubkey" } ] } \ No newline at end of file diff --git a/sdk/serviceability/testdata/fixtures/topology_info.bin b/sdk/serviceability/testdata/fixtures/topology_info.bin new file mode 100644 index 0000000000000000000000000000000000000000..9f4db1e1b0de9a082d139550ebc3cab3c5a787a9 GIT binary patch literal 56 icmWf5zpnK2Sx?}5=;YS delta 6 Ncmey!_ Date: Thu, 2 Apr 2026 10:14:39 -0500 Subject: [PATCH 33/49] sdk: fix InterfaceV3 deserialization in Go, Python, and TypeScript SDKs --- sdk/serviceability/go/bytereader.go | 13 ++++++ sdk/serviceability/go/deserialize.go | 20 +++++++++ sdk/serviceability/go/state.go | 42 +++++++++++-------- .../python/serviceability/state.py | 30 ++++--------- .../typescript/serviceability/state.ts | 39 ++++++----------- .../sdk/go/serviceability/deserialize.go | 21 ++++++++++ smartcontract/sdk/go/serviceability/state.go | 2 +- 7 files changed, 100 insertions(+), 67 deletions(-) diff --git a/sdk/serviceability/go/bytereader.go b/sdk/serviceability/go/bytereader.go index 056df085df..ac93bb07a8 100644 --- a/sdk/serviceability/go/bytereader.go +++ b/sdk/serviceability/go/bytereader.go @@ -63,6 +63,19 @@ func (br *ByteReader) ReadBytes(n int) []byte { return v } +func (br *ByteReader) ReadFlexAlgoNodeSegmentSlice() []FlexAlgoNodeSegment { + length := br.ReadU32() + if length == 0 { + return nil + } + result := make([]FlexAlgoNodeSegment, length) + for i := uint32(0); i < length; i++ { + result[i].Topology = br.ReadPubkey() + result[i].NodeSegmentIdx = br.ReadU16() + } + return result +} + func (br *ByteReader) DumpBytes(n int) string { // Preserved for compatibility but uses offset from underlying reader. return "" diff --git a/sdk/serviceability/go/deserialize.go b/sdk/serviceability/go/deserialize.go index eebc5cc62c..b06768eb88 100644 --- a/sdk/serviceability/go/deserialize.go +++ b/sdk/serviceability/go/deserialize.go @@ -77,6 +77,8 @@ func DeserializeInterface(reader *ByteReader, iface *Interface) { DeserializeInterfaceV1(reader, iface) case 1: // version 2 DeserializeInterfaceV2(reader, iface) + case 2: // version 3 + DeserializeInterfaceV3(reader, iface) } } @@ -108,6 +110,24 @@ func DeserializeInterfaceV2(reader *ByteReader, iface *Interface) { iface.UserTunnelEndpoint = reader.ReadBool() } +func DeserializeInterfaceV3(reader *ByteReader, iface *Interface) { + iface.Status = InterfaceStatus(reader.ReadU8()) + iface.Name = reader.ReadString() + iface.InterfaceType = InterfaceType(reader.ReadU8()) + iface.InterfaceCYOA = InterfaceCYOA(reader.ReadU8()) + iface.InterfaceDIA = InterfaceDIA(reader.ReadU8()) + iface.LoopbackType = LoopbackType(reader.ReadU8()) + iface.Bandwidth = reader.ReadU64() + iface.Cir = reader.ReadU64() + iface.Mtu = reader.ReadU16() + iface.RoutingMode = RoutingMode(reader.ReadU8()) + iface.VlanId = reader.ReadU16() + iface.IpNet = reader.ReadNetworkV4() + iface.NodeSegmentIdx = reader.ReadU16() + iface.UserTunnelEndpoint = reader.ReadBool() + iface.FlexAlgoNodeSegments = reader.ReadFlexAlgoNodeSegmentSlice() +} + func DeserializeDevice(reader *ByteReader, dev *Device) { dev.AccountType = AccountType(reader.ReadU8()) dev.Owner = reader.ReadPubkey() diff --git a/sdk/serviceability/go/state.go b/sdk/serviceability/go/state.go index da14a688ff..02203097d7 100644 --- a/sdk/serviceability/go/state.go +++ b/sdk/serviceability/go/state.go @@ -301,25 +301,31 @@ const ( RoutingModeBGP RoutingMode = 1 ) +type FlexAlgoNodeSegment struct { + Topology [32]byte + NodeSegmentIdx uint16 +} + type Interface struct { - Version uint8 - Status InterfaceStatus - Name string - InterfaceType InterfaceType - InterfaceCYOA InterfaceCYOA - InterfaceDIA InterfaceDIA - LoopbackType LoopbackType - Bandwidth uint64 - Cir uint64 - Mtu uint16 - RoutingMode RoutingMode - VlanId uint16 - IpNet [5]uint8 - NodeSegmentIdx uint16 - UserTunnelEndpoint bool -} - -const CurrentInterfaceVersion = 2 + Version uint8 + Status InterfaceStatus + Name string + InterfaceType InterfaceType + InterfaceCYOA InterfaceCYOA + InterfaceDIA InterfaceDIA + LoopbackType LoopbackType + Bandwidth uint64 + Cir uint64 + Mtu uint16 + RoutingMode RoutingMode + VlanId uint16 + IpNet [5]uint8 + NodeSegmentIdx uint16 + UserTunnelEndpoint bool + FlexAlgoNodeSegments []FlexAlgoNodeSegment +} + +const CurrentInterfaceVersion = 3 type Uint128 struct { Low uint64 diff --git a/sdk/serviceability/python/serviceability/state.py b/sdk/serviceability/python/serviceability/state.py index f9942699af..1b0058be89 100644 --- a/sdk/serviceability/python/serviceability/state.py +++ b/sdk/serviceability/python/serviceability/state.py @@ -426,7 +426,7 @@ def from_reader(cls, r: IncrementalReader) -> Interface: iface.ip_net = r.read_network_v4() iface.node_segment_idx = r.read_u16() iface.user_tunnel_endpoint = r.read_bool() - elif iface.version == 1: # V2 + elif iface.version in (1, 2): # V2 or V3 iface.status = InterfaceStatus(r.read_u8()) iface.name = r.read_string() iface.interface_type = InterfaceType(r.read_u8()) @@ -441,27 +441,13 @@ def from_reader(cls, r: IncrementalReader) -> Interface: iface.ip_net = r.read_network_v4() iface.node_segment_idx = r.read_u16() iface.user_tunnel_endpoint = r.read_bool() - elif iface.version == 2: # V3 - iface.status = InterfaceStatus(r.read_u8()) - iface.name = r.read_string() - iface.interface_type = InterfaceType(r.read_u8()) - iface.interface_cyoa = InterfaceCYOA(r.read_u8()) - iface.interface_dia = InterfaceDIA(r.read_u8()) - iface.loopback_type = LoopbackType(r.read_u8()) - iface.bandwidth = r.read_u64() - iface.cir = r.read_u64() - iface.mtu = r.read_u16() - iface.routing_mode = RoutingMode(r.read_u8()) - iface.vlan_id = r.read_u16() - iface.ip_net = r.read_network_v4() - iface.node_segment_idx = r.read_u16() - iface.user_tunnel_endpoint = r.read_bool() - count = r.read_u32() - for _ in range(count): - seg = FlexAlgoNodeSegment() - seg.topology = _read_pubkey(r) - seg.node_segment_idx = r.read_u16() - iface.flex_algo_node_segments.append(seg) + if iface.version == 2: # V3 + count = r.read_u32() + for _ in range(count): + seg = FlexAlgoNodeSegment() + seg.topology = _read_pubkey(r) + seg.node_segment_idx = r.read_u16() + iface.flex_algo_node_segments.append(seg) return iface diff --git a/sdk/serviceability/typescript/serviceability/state.ts b/sdk/serviceability/typescript/serviceability/state.ts index 846ad6efa2..6f4cfa04ac 100644 --- a/sdk/serviceability/typescript/serviceability/state.ts +++ b/sdk/serviceability/typescript/serviceability/state.ts @@ -511,8 +511,8 @@ function deserializeInterface(r: DefensiveReader): DeviceInterface { iface.ipNet = r.readNetworkV4(); iface.nodeSegmentIdx = r.readU16(); iface.userTunnelEndpoint = r.readBool(); - } else if (iface.version === 1) { - // V2 + } else if (iface.version === 1 || iface.version === 2) { + // V2 or V3 iface.status = r.readU8(); iface.name = r.readString(); iface.interfaceType = r.readU8(); @@ -527,31 +527,18 @@ function deserializeInterface(r: DefensiveReader): DeviceInterface { iface.ipNet = r.readNetworkV4(); iface.nodeSegmentIdx = r.readU16(); iface.userTunnelEndpoint = r.readBool(); - } else if (iface.version === 2) { - // V3 - iface.status = r.readU8(); - iface.name = r.readString(); - iface.interfaceType = r.readU8(); - iface.interfaceCyoa = r.readU8(); - iface.interfaceDia = r.readU8(); - iface.loopbackType = r.readU8(); - iface.bandwidth = r.readU64(); - iface.cir = r.readU64(); - iface.mtu = r.readU16(); - iface.routingMode = r.readU8(); - iface.vlanId = r.readU16(); - iface.ipNet = r.readNetworkV4(); - iface.nodeSegmentIdx = r.readU16(); - iface.userTunnelEndpoint = r.readBool(); - const segCount = r.readU32(); - const flexAlgoNodeSegments: FlexAlgoNodeSegment[] = []; - for (let i = 0; i < segCount; i++) { - flexAlgoNodeSegments.push({ - topology: readPubkey(r), - nodeSegmentIdx: r.readU16(), - }); + if (iface.version === 2) { + // V3 + const segCount = r.readU32(); + const flexAlgoNodeSegments: FlexAlgoNodeSegment[] = []; + for (let i = 0; i < segCount; i++) { + flexAlgoNodeSegments.push({ + topology: readPubkey(r), + nodeSegmentIdx: r.readU16(), + }); + } + iface.flexAlgoNodeSegments = flexAlgoNodeSegments; } - iface.flexAlgoNodeSegments = flexAlgoNodeSegments; } return iface; diff --git a/smartcontract/sdk/go/serviceability/deserialize.go b/smartcontract/sdk/go/serviceability/deserialize.go index 71f53066f0..809d172538 100644 --- a/smartcontract/sdk/go/serviceability/deserialize.go +++ b/smartcontract/sdk/go/serviceability/deserialize.go @@ -89,6 +89,8 @@ func DeserializeInterface(reader *ByteReader, iface *Interface) { DeserializeInterfaceV1(reader, iface) case 1: // version 2 DeserializeInterfaceV2(reader, iface) + case 2: // version 3 + DeserializeInterfaceV3(reader, iface) } } @@ -121,6 +123,25 @@ func DeserializeInterfaceV2(reader *ByteReader, iface *Interface) { iface.UserTunnelEndpoint = (reader.ReadU8() != 0) } +func DeserializeInterfaceV3(reader *ByteReader, iface *Interface) { + iface.Status = InterfaceStatus(reader.ReadU8()) + iface.Name = reader.ReadString() + iface.InterfaceType = InterfaceType(reader.ReadU8()) + iface.InterfaceCYOA = InterfaceCYOA(reader.ReadU8()) + iface.InterfaceDIA = InterfaceDIA(reader.ReadU8()) + loopbackTypeByte := reader.ReadU8() + iface.LoopbackType = LoopbackType(loopbackTypeByte) + iface.Bandwidth = reader.ReadU64() + iface.Cir = reader.ReadU64() + iface.Mtu = reader.ReadU16() + iface.RoutingMode = RoutingMode(reader.ReadU8()) + iface.VlanId = reader.ReadU16() + iface.IpNet = reader.ReadNetworkV4() + iface.NodeSegmentIdx = reader.ReadU16() + iface.UserTunnelEndpoint = (reader.ReadU8() != 0) + iface.FlexAlgoNodeSegments = reader.ReadFlexAlgoNodeSegmentSlice() +} + func DeserializeDevice(reader *ByteReader, dev *Device) { dev.AccountType = AccountType(reader.ReadU8()) dev.Owner = reader.ReadPubkey() diff --git a/smartcontract/sdk/go/serviceability/state.go b/smartcontract/sdk/go/serviceability/state.go index 2f5a7ffc55..53aa81f146 100644 --- a/smartcontract/sdk/go/serviceability/state.go +++ b/smartcontract/sdk/go/serviceability/state.go @@ -377,7 +377,7 @@ func (i Interface) MarshalJSON() ([]byte, error) { return json.Marshal(jsonIface) } -const CurrentInterfaceVersion = 2 +const CurrentInterfaceVersion = 3 type Device struct { AccountType AccountType From 85d9271a091e269cb9b22854e67293737e2bea05 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 2 Apr 2026 13:10:23 -0500 Subject: [PATCH 34/49] sdk: fix duplicate payer in execute_transaction_inner when payer already in accounts topology clear --links was broken: execute_transaction_inner always appended payer at the end, but clear.rs also included payer explicitly at [2]. Solana deduplicates by removing the first occurrence, causing the link account to shift into payer's position. The signer check then fails. Fix: skip appending payer if it is already present in the accounts list. Incidentally formats cli/src/topology/create.rs (nightly rustfmt). --- smartcontract/cli/src/topology/create.rs | 10 ++++-- .../sdk/go/serviceability/bytereader.go | 13 +++++++ smartcontract/sdk/go/serviceability/state.go | 36 +++++++++++-------- smartcontract/sdk/rs/src/client.rs | 16 ++++----- 4 files changed, 50 insertions(+), 25 deletions(-) diff --git a/smartcontract/cli/src/topology/create.rs b/smartcontract/cli/src/topology/create.rs index febdaeedc0..0fb83a2fec 100644 --- a/smartcontract/cli/src/topology/create.rs +++ b/smartcontract/cli/src/topology/create.rs @@ -31,7 +31,10 @@ fn parse_constraint(s: &str) -> Result { impl CreateTopologyCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { if self.name.len() > 32 { - eyre::bail!("topology name must be 32 characters or fewer (got {})", self.name.len()); + eyre::bail!( + "topology name must be 32 characters or fewer (got {})", + self.name.len() + ); } client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; @@ -112,6 +115,9 @@ mod tests { let mut out = Cursor::new(Vec::new()); let result = cmd.execute(&mock, &mut out); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("32 characters or fewer")); + assert!(result + .unwrap_err() + .to_string() + .contains("32 characters or fewer")); } } diff --git a/smartcontract/sdk/go/serviceability/bytereader.go b/smartcontract/sdk/go/serviceability/bytereader.go index fa3fd479c4..441b36b7e3 100644 --- a/smartcontract/sdk/go/serviceability/bytereader.go +++ b/smartcontract/sdk/go/serviceability/bytereader.go @@ -180,3 +180,16 @@ func (br *ByteReader) Skip(n int) { br.offset = len(br.data) } } + +func (br *ByteReader) ReadFlexAlgoNodeSegmentSlice() []FlexAlgoNodeSegment { + length := br.ReadU32() + if length == 0 || (uint64(length)*34) > uint64(br.Remaining()) { + return nil + } + result := make([]FlexAlgoNodeSegment, length) + for i := uint32(0); i < length; i++ { + result[i].Topology = br.ReadPubkey() + result[i].NodeSegmentIdx = br.ReadU16() + } + return result +} diff --git a/smartcontract/sdk/go/serviceability/state.go b/smartcontract/sdk/go/serviceability/state.go index 53aa81f146..d518d25392 100644 --- a/smartcontract/sdk/go/serviceability/state.go +++ b/smartcontract/sdk/go/serviceability/state.go @@ -337,22 +337,28 @@ func (l RoutingMode) MarshalJSON() ([]byte, error) { return json.Marshal(l.String()) } +type FlexAlgoNodeSegment struct { + Topology [32]uint8 + NodeSegmentIdx uint16 +} + type Interface struct { - Version uint8 - Status InterfaceStatus - Name string - InterfaceType InterfaceType - InterfaceCYOA InterfaceCYOA - InterfaceDIA InterfaceDIA - LoopbackType LoopbackType - Bandwidth uint64 - Cir uint64 - Mtu uint16 - RoutingMode RoutingMode - VlanId uint16 - IpNet [5]uint8 - NodeSegmentIdx uint16 - UserTunnelEndpoint bool + Version uint8 + Status InterfaceStatus + Name string + InterfaceType InterfaceType + InterfaceCYOA InterfaceCYOA + InterfaceDIA InterfaceDIA + LoopbackType LoopbackType + Bandwidth uint64 + Cir uint64 + Mtu uint16 + RoutingMode RoutingMode + VlanId uint16 + IpNet [5]uint8 + NodeSegmentIdx uint16 + UserTunnelEndpoint bool + FlexAlgoNodeSegments []FlexAlgoNodeSegment } func (i Interface) MarshalJSON() ([]byte, error) { diff --git a/smartcontract/sdk/rs/src/client.rs b/smartcontract/sdk/rs/src/client.rs index ff243aaa09..0a4c9fec70 100644 --- a/smartcontract/sdk/rs/src/client.rs +++ b/smartcontract/sdk/rs/src/client.rs @@ -136,18 +136,18 @@ impl DZClient { .ok_or_eyre("No default signer found, run \"doublezero keygen\" to create a new one")?; let data = instruction.pack(); + let payer_pubkey = payer.pubkey(); + let mut all_accounts = accounts; + if !all_accounts.iter().any(|a| a.pubkey == payer_pubkey) { + all_accounts.push(AccountMeta::new(payer_pubkey, true)); + } + all_accounts.push(AccountMeta::new(program::id(), false)); + let mut transaction = Transaction::new_with_payer( &[Instruction::new_with_bytes( self.program_id, &data, - [ - accounts, - vec![ - AccountMeta::new(payer.pubkey(), true), - AccountMeta::new(program::id(), false), - ], - ] - .concat(), + all_accounts, )], Some(&payer.pubkey()), ); From cb4e3c5f14cb71271966e8ac1452ac50707f319f Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 2 Apr 2026 14:30:20 -0500 Subject: [PATCH 35/49] sdk/serviceability: add FlexAlgoNodeSegment type to Python and TypeScript SDKs Cherry-pick added V3 deserialization logic but not the type declarations. Add FlexAlgoNodeSegment dataclass/interface and flexAlgoNodeSegments field to Interface/DeviceInterface in both SDKs. --- sdk/serviceability/python/serviceability/state.py | 7 +++++++ sdk/serviceability/typescript/serviceability/state.ts | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/sdk/serviceability/python/serviceability/state.py b/sdk/serviceability/python/serviceability/state.py index 1b0058be89..129765cd2d 100644 --- a/sdk/serviceability/python/serviceability/state.py +++ b/sdk/serviceability/python/serviceability/state.py @@ -393,6 +393,12 @@ def __str__(self) -> str: CURRENT_INTERFACE_VERSION = 3 +@dataclass +class FlexAlgoNodeSegment: + topology: bytes = b"\x00" * 32 + node_segment_idx: int = 0 + + @dataclass class Interface: version: int = 0 @@ -410,6 +416,7 @@ class Interface: ip_net: bytes = b"\x00" * 5 node_segment_idx: int = 0 user_tunnel_endpoint: bool = False + flex_algo_node_segments: list["FlexAlgoNodeSegment"] = field(default_factory=list) @classmethod def from_reader(cls, r: IncrementalReader) -> Interface: diff --git a/sdk/serviceability/typescript/serviceability/state.ts b/sdk/serviceability/typescript/serviceability/state.ts index 6f4cfa04ac..01b5262355 100644 --- a/sdk/serviceability/typescript/serviceability/state.ts +++ b/sdk/serviceability/typescript/serviceability/state.ts @@ -457,6 +457,11 @@ export function deserializeExchange(data: Uint8Array): Exchange { // Interface (versioned, embedded in Device) // --------------------------------------------------------------------------- +export interface FlexAlgoNodeSegment { + topology: PublicKey; + nodeSegmentIdx: number; +} + export interface DeviceInterface { version: number; status: number; @@ -473,6 +478,7 @@ export interface DeviceInterface { ipNet: Uint8Array; nodeSegmentIdx: number; userTunnelEndpoint: boolean; + flexAlgoNodeSegments?: FlexAlgoNodeSegment[]; } const CURRENT_INTERFACE_VERSION = 3; @@ -494,6 +500,7 @@ function deserializeInterface(r: DefensiveReader): DeviceInterface { ipNet: new Uint8Array(5), nodeSegmentIdx: 0, userTunnelEndpoint: false, + flexAlgoNodeSegments: [], }; iface.version = r.readU8(); From 106c5a2e60860bad56f8adadf76136de425fad83 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 2 Apr 2026 14:30:31 -0500 Subject: [PATCH 36/49] smartcontract: rustfmt formatting fixes in instructions.rs --- .../doublezero-serviceability/src/instructions.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index 75397ef969..d035aa865d 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -497,9 +497,9 @@ impl DoubleZeroInstruction { Self::Deprecated102() => "Deprecated102".to_string(), Self::Deprecated103() => "Deprecated103".to_string(), - Self::CreateTopology(_) => "CreateTopology".to_string(), // variant 104 - Self::DeleteTopology(_) => "DeleteTopology".to_string(), // variant 105 - Self::ClearTopology(_) => "ClearTopology".to_string(), // variant 106 + Self::CreateTopology(_) => "CreateTopology".to_string(), // variant 104 + Self::DeleteTopology(_) => "DeleteTopology".to_string(), // variant 105 + Self::ClearTopology(_) => "ClearTopology".to_string(), // variant 106 Self::BackfillTopology(_) => "BackfillTopology".to_string(), // variant 107 } } @@ -628,10 +628,10 @@ impl DoubleZeroInstruction { Self::Deprecated102() => String::new(), Self::Deprecated103() => String::new(), - Self::CreateTopology(args) => format!("{args:?}"), // variant 104 - Self::DeleteTopology(args) => format!("{args:?}"), // variant 105 - Self::ClearTopology(args) => format!("{args:?}"), // variant 106 - Self::BackfillTopology(args) => format!("{args:?}"), // variant 107 + Self::CreateTopology(args) => format!("{args:?}"), // variant 104 + Self::DeleteTopology(args) => format!("{args:?}"), // variant 105 + Self::ClearTopology(args) => format!("{args:?}"), // variant 106 + Self::BackfillTopology(args) => format!("{args:?}"), // variant 107 } } } From 673ab56e8a83fb0e454970a2a8dc96bacbd741bc Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 2 Apr 2026 15:55:49 -0500 Subject: [PATCH 37/49] controlplane/controller: fix Interface zero-value comparison after slice field addition --- controlplane/controller/internal/controller/models.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controlplane/controller/internal/controller/models.go b/controlplane/controller/internal/controller/models.go index 2c8a0add5b..dcf970c675 100644 --- a/controlplane/controller/internal/controller/models.go +++ b/controlplane/controller/internal/controller/models.go @@ -49,7 +49,7 @@ type Interface struct { // toInterface validates onchain data for a serviceability interface and converts it to a controller interface. func toInterface(iface serviceability.Interface) (Interface, error) { - if iface == (serviceability.Interface{}) { + if iface.IpNet == ([5]byte{}) && iface.Name == "" { return Interface{}, errors.New("serviceability interface cannot be nil") } From d99304f2cea7c9805d6c3c755b5db3b86d19776b Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 6 Apr 2026 14:12:11 -0500 Subject: [PATCH 38/49] smartcontract: add link_flags, topology list/filter CLI, SDK updates for RFC-18 --- activator/src/process/link.rs | 10 ++-- client/doublezero/src/dzd_latency.rs | 2 +- .../doublezero-admin/src/cli/command.rs | 2 +- .../doublezero-admin/src/cli/migrate.rs | 16 +++++- controlplane/doublezero-admin/src/main.rs | 5 +- sdk/serviceability/go/deserialize.go | 35 +++++++----- sdk/serviceability/go/state.go | 33 +++++++++++ .../python/serviceability/state.py | 54 ++++++++++++++++-- .../testdata/fixtures/device.bin | Bin 311 -> 315 bytes .../fixtures/generate-fixtures/src/main.rs | 5 +- .../testdata/fixtures/link.json | 6 +- .../fixtures/generate-fixtures/Cargo.lock | 8 +-- smartcontract/cli/src/link/accept.rs | 4 +- smartcontract/cli/src/link/delete.rs | 4 +- smartcontract/cli/src/link/dzx_create.rs | 36 ++++++------ smartcontract/cli/src/link/get.rs | 10 ++-- smartcontract/cli/src/link/latency.rs | 4 +- smartcontract/cli/src/link/list.rs | 53 ++++++++++++----- smartcontract/cli/src/link/sethealth.rs | 8 +-- 19 files changed, 209 insertions(+), 86 deletions(-) diff --git a/activator/src/process/link.rs b/activator/src/process/link.rs index 347fe13e2e..1444bdc04c 100644 --- a/activator/src/process/link.rs +++ b/activator/src/process/link.rs @@ -273,7 +273,7 @@ mod tests { doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let tunnel_cloned = tunnel.clone(); @@ -402,7 +402,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let link_cloned = link.clone(); @@ -465,7 +465,7 @@ mod tests { doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let tunnel_clone = tunnel.clone(); @@ -555,7 +555,7 @@ mod tests { doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; // SDK command fetches the link internally @@ -637,7 +637,7 @@ mod tests { doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; // SDK command fetches the link internally diff --git a/client/doublezero/src/dzd_latency.rs b/client/doublezero/src/dzd_latency.rs index cec4b93214..58a56c947d 100644 --- a/client/doublezero/src/dzd_latency.rs +++ b/client/doublezero/src/dzd_latency.rs @@ -259,7 +259,7 @@ mod tests { .into_iter() .enumerate() .map(|(i, ip)| { - Interface::V3(CurrentInterfaceVersion { + Interface::V2(CurrentInterfaceVersion { status: InterfaceStatus::Activated, name: format!("Loopback{}", i), interface_type: InterfaceType::Loopback, diff --git a/controlplane/doublezero-admin/src/cli/command.rs b/controlplane/doublezero-admin/src/cli/command.rs index bb69133027..602d3fbbc1 100644 --- a/controlplane/doublezero-admin/src/cli/command.rs +++ b/controlplane/doublezero-admin/src/cli/command.rs @@ -66,7 +66,7 @@ pub enum Command { /// Manage multicast #[command()] Multicast(MulticastCliCommand), - /// Backfill link topologies and report Vpnv4 loopback gaps (RFC-18 migration) + /// RFC-18 migration subcommands #[command()] Migrate(MigrateCliCommand), /// Sentinel admin commands diff --git a/controlplane/doublezero-admin/src/cli/migrate.rs b/controlplane/doublezero-admin/src/cli/migrate.rs index ac0de05cec..6eebb850d4 100644 --- a/controlplane/doublezero-admin/src/cli/migrate.rs +++ b/controlplane/doublezero-admin/src/cli/migrate.rs @@ -1,4 +1,4 @@ -use clap::Args; +use clap::{Args, Subcommand}; use doublezero_cli::doublezerocommand::CliCommand; use doublezero_sdk::commands::{ device::list::ListDeviceCommand, @@ -11,12 +11,24 @@ use std::io::Write; #[derive(Args, Debug)] pub struct MigrateCliCommand { + #[command(subcommand)] + pub command: MigrateCommands, +} + +#[derive(Debug, Subcommand)] +pub enum MigrateCommands { + /// Backfill link topologies and Vpnv4 loopback FlexAlgoNodeSegments (RFC-18 migration) + FlexAlgo(FlexAlgoMigrateCliCommand), +} + +#[derive(Args, Debug)] +pub struct FlexAlgoMigrateCliCommand { /// Print what would be changed without submitting transactions #[arg(long, default_value_t = false)] pub dry_run: bool, } -impl MigrateCliCommand { +impl FlexAlgoMigrateCliCommand { pub fn execute(&self, client: &C, out: &mut W) -> eyre::Result<()> { let program_id = client.get_program_id(); diff --git a/controlplane/doublezero-admin/src/main.rs b/controlplane/doublezero-admin/src/main.rs index 0c6884895b..6ca710c0da 100644 --- a/controlplane/doublezero-admin/src/main.rs +++ b/controlplane/doublezero-admin/src/main.rs @@ -247,7 +247,10 @@ async fn main() -> eyre::Result<()> { }, }, - Command::Migrate(args) => args.execute(&client, &mut handle), + Command::Migrate(args) => match args.command { + cli::migrate::MigrateCommands::FlexAlgo(cmd) => cmd.execute(&client, &mut handle), + }, + Command::Sentinel(args) => match args.command { cli::sentinel::SentinelCommands::FindValidatorMulticastPublishers(cmd) => { cmd.execute(&dzclient).await diff --git a/sdk/serviceability/go/deserialize.go b/sdk/serviceability/go/deserialize.go index b06768eb88..5486eb6013 100644 --- a/sdk/serviceability/go/deserialize.go +++ b/sdk/serviceability/go/deserialize.go @@ -108,24 +108,16 @@ func DeserializeInterfaceV2(reader *ByteReader, iface *Interface) { iface.IpNet = reader.ReadNetworkV4() iface.NodeSegmentIdx = reader.ReadU16() iface.UserTunnelEndpoint = reader.ReadBool() + // flex_algo_node_segments was merged into V2 from the old V3. + // Old V2 accounts (written before this field existed) will have no trailing + // bytes — ReadFlexAlgoNodeSegmentSlice returns nil/empty in that case. + iface.FlexAlgoNodeSegments = reader.ReadFlexAlgoNodeSegmentSlice() } +// DeserializeInterfaceV3 handles legacy on-chain accounts written with +// discriminant 2 (the old V3). Their layout is identical to the current V2. func DeserializeInterfaceV3(reader *ByteReader, iface *Interface) { - iface.Status = InterfaceStatus(reader.ReadU8()) - iface.Name = reader.ReadString() - iface.InterfaceType = InterfaceType(reader.ReadU8()) - iface.InterfaceCYOA = InterfaceCYOA(reader.ReadU8()) - iface.InterfaceDIA = InterfaceDIA(reader.ReadU8()) - iface.LoopbackType = LoopbackType(reader.ReadU8()) - iface.Bandwidth = reader.ReadU64() - iface.Cir = reader.ReadU64() - iface.Mtu = reader.ReadU16() - iface.RoutingMode = RoutingMode(reader.ReadU8()) - iface.VlanId = reader.ReadU16() - iface.IpNet = reader.ReadNetworkV4() - iface.NodeSegmentIdx = reader.ReadU16() - iface.UserTunnelEndpoint = reader.ReadBool() - iface.FlexAlgoNodeSegments = reader.ReadFlexAlgoNodeSegmentSlice() + DeserializeInterfaceV2(reader, iface) } func DeserializeDevice(reader *ByteReader, dev *Device) { @@ -190,6 +182,8 @@ func DeserializeLink(reader *ByteReader, link *Link) { link.DelayOverrideNs = reader.ReadU64() link.LinkHealth = LinkHealth(reader.ReadU8()) link.LinkDesiredStatus = LinkDesiredStatus(reader.ReadU8()) + link.LinkTopologies = reader.ReadPubkeySlice() + link.LinkFlags = reader.ReadU8() } func DeserializeUser(reader *ByteReader, user *User) { @@ -344,6 +338,16 @@ func DeserializePermission(reader *ByteReader, perm *Permission) { perm.PermissionsHi = u128.High } +func DeserializeTopologyInfo(reader *ByteReader, t *TopologyInfo) { + t.AccountType = AccountType(reader.ReadU8()) + t.Owner = reader.ReadPubkey() + t.BumpSeed = reader.ReadU8() + t.Name = reader.ReadString() + t.AdminGroupBit = reader.ReadU8() + t.FlexAlgoNumber = reader.ReadU8() + t.Constraint = TopologyConstraint(reader.ReadU8()) +} + func DeserializeTenant(reader *ByteReader, tenant *Tenant) { tenant.AccountType = AccountType(reader.ReadU8()) tenant.Owner = reader.ReadPubkey() @@ -359,5 +363,6 @@ func DeserializeTenant(reader *ByteReader, tenant *Tenant) { tenant.BillingDiscriminant = reader.ReadU8() tenant.BillingRate = reader.ReadU64() tenant.BillingLastDeductionDzEpoch = reader.ReadU64() + tenant.IncludeTopologies = reader.ReadPubkeySlice() // Note: tenant.PubKey is set separately in client.go after deserialization } diff --git a/sdk/serviceability/go/state.go b/sdk/serviceability/go/state.go index 02203097d7..1dfa1758b3 100644 --- a/sdk/serviceability/go/state.go +++ b/sdk/serviceability/go/state.go @@ -25,6 +25,7 @@ const ( ResourceExtensionType AccountType = 12 TenantType AccountType = 13 PermissionType AccountType = 15 + TopologyType AccountType = 16 ) type LocationStatus uint8 @@ -607,6 +608,8 @@ type Link struct { LinkHealth LinkHealth LinkDesiredStatus LinkDesiredStatus PubKey [32]byte + LinkTopologies [][32]byte + LinkFlags uint8 } type ContributorStatus uint8 @@ -1051,6 +1054,7 @@ type Tenant struct { BillingRate uint64 `influx:"field,billing_rate"` BillingLastDeductionDzEpoch uint64 `influx:"field,billing_last_deduction_dz_epoch"` PubKey [32]byte `influx:"tag,pubkey,pubkey"` + IncludeTopologies [][32]byte } func (t Tenant) MarshalJSON() ([]byte, error) { @@ -1138,3 +1142,32 @@ type Permission struct { PermissionsHi uint64 PubKey [32]byte } + +type TopologyConstraint uint8 + +const ( + TopologyConstraintIncludeAny TopologyConstraint = 0 + TopologyConstraintExclude TopologyConstraint = 1 +) + +func (c TopologyConstraint) String() string { + switch c { + case TopologyConstraintIncludeAny: + return "include-any" + case TopologyConstraintExclude: + return "exclude" + default: + return "unknown" + } +} + +type TopologyInfo struct { + AccountType AccountType + Owner [32]byte + BumpSeed uint8 + Name string + AdminGroupBit uint8 + FlexAlgoNumber uint8 + Constraint TopologyConstraint + PubKey [32]byte +} diff --git a/sdk/serviceability/python/serviceability/state.py b/sdk/serviceability/python/serviceability/state.py index 129765cd2d..78b80ada89 100644 --- a/sdk/serviceability/python/serviceability/state.py +++ b/sdk/serviceability/python/serviceability/state.py @@ -42,6 +42,7 @@ class AccountTypeEnum(IntEnum): ACCESS_PASS = 11 TENANT = 13 PERMISSION = 15 + TOPOLOGY = 16 # --------------------------------------------------------------------------- @@ -395,7 +396,7 @@ def __str__(self) -> str: @dataclass class FlexAlgoNodeSegment: - topology: bytes = b"\x00" * 32 + topology: Pubkey = Pubkey.default() node_segment_idx: int = 0 @@ -416,10 +417,10 @@ class Interface: ip_net: bytes = b"\x00" * 5 node_segment_idx: int = 0 user_tunnel_endpoint: bool = False - flex_algo_node_segments: list["FlexAlgoNodeSegment"] = field(default_factory=list) + flex_algo_node_segments: list[FlexAlgoNodeSegment] = field(default_factory=list) @classmethod - def from_reader(cls, r: IncrementalReader) -> Interface: + def from_reader(cls, r: DefensiveReader) -> Interface: iface = cls() iface.version = r.read_u8() if iface.version > CURRENT_INTERFACE_VERSION - 1: @@ -448,7 +449,7 @@ def from_reader(cls, r: IncrementalReader) -> Interface: iface.ip_net = r.read_network_v4() iface.node_segment_idx = r.read_u16() iface.user_tunnel_endpoint = r.read_bool() - if iface.version == 2: # V3 + if iface.version in (1, 2): # flex_algo_node_segments present in V2 and V3 count = r.read_u32() for _ in range(count): seg = FlexAlgoNodeSegment() @@ -683,6 +684,8 @@ class Link: delay_override_ns: int = 0 link_health: LinkHealth = LinkHealth.UNKNOWN link_desired_status: LinkDesiredStatus = LinkDesiredStatus.PENDING + link_topologies: list[Pubkey] = field(default_factory=list) + link_flags: int = 0 @classmethod def from_bytes(cls, data: bytes) -> Link: @@ -709,6 +712,8 @@ def from_bytes(cls, data: bytes) -> Link: lk.delay_override_ns = r.read_u64() lk.link_health = LinkHealth(r.read_u8()) lk.link_desired_status = LinkDesiredStatus(r.read_u8()) + lk.link_topologies = _read_pubkey_vec(r) + lk.link_flags = r.read_u8() return lk @@ -862,6 +867,7 @@ class Tenant: billing_discriminant: int = 0 billing_rate: int = 0 billing_last_deduction_dz_epoch: int = 0 + include_topologies: list[Pubkey] = field(default_factory=list) @classmethod def from_bytes(cls, data: bytes) -> Tenant: @@ -881,6 +887,7 @@ def from_bytes(cls, data: bytes) -> Tenant: t.billing_discriminant = r.read_u8() t.billing_rate = r.read_u64() t.billing_last_deduction_dz_epoch = r.read_u64() + t.include_topologies = _read_pubkey_vec(r) return t @@ -989,3 +996,42 @@ def from_bytes(cls, data: bytes) -> Permission: hi = r.read_u64() p.permissions = lo | (hi << 64) return p + + +# --------------------------------------------------------------------------- +# TopologyInfo +# --------------------------------------------------------------------------- + + +class TopologyConstraint(IntEnum): + INCLUDE_ANY = 0 + EXCLUDE = 1 + + def __str__(self) -> str: + _names = {0: "include-any", 1: "exclude"} + return _names.get(self.value, "unknown") + + +@dataclass +class TopologyInfo: + account_type: int = 0 + owner: Pubkey = Pubkey.default() + bump_seed: int = 0 + name: str = "" + admin_group_bit: int = 0 + flex_algo_number: int = 0 + constraint: TopologyConstraint = TopologyConstraint.INCLUDE_ANY + pub_key: Pubkey = Pubkey.default() # set from account address after deserialization + + @classmethod + def from_bytes(cls, data: bytes) -> TopologyInfo: + r = DefensiveReader(data) + t = cls() + t.account_type = r.read_u8() + t.owner = _read_pubkey(r) + t.bump_seed = r.read_u8() + t.name = r.read_string() + t.admin_group_bit = r.read_u8() + t.flex_algo_number = r.read_u8() + t.constraint = TopologyConstraint(r.read_u8()) + return t diff --git a/sdk/serviceability/testdata/fixtures/device.bin b/sdk/serviceability/testdata/fixtures/device.bin index 8554f47fb112076f8832b9d02e1856f69172d6a7..5e322d10ae5b6e1817d0a5c4a3f3ecc3bfc09d9a 100644 GIT binary patch delta 16 Wcmdnaw3}&zJR=JO5KNY5lmGxCcmoyy delta 32 mcmdnZw4G^#Jfj2;0|NsqLka^kBQpaNgAouiGcYo6F#rHl90N}P diff --git a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs index 8fa2494214..8dd4a069a8 100644 --- a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs +++ b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs @@ -324,6 +324,7 @@ fn generate_device(dir: &Path) { ip_net: "172.16.0.1/30".parse().unwrap(), node_segment_idx: 200, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], }), ], reference_count: 12, @@ -436,7 +437,7 @@ fn generate_link(dir: &Path) { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: vec![topology_pk], - unicast_drained: true, + link_flags: doublezero_serviceability::state::link::LINK_FLAG_UNICAST_DRAINED, }; let data = borsh::to_vec(&val).unwrap(); @@ -468,7 +469,7 @@ fn generate_link(dir: &Path) { FieldValue { name: "DesiredStatus".into(), value: "1".into(), typ: "u8".into() }, FieldValue { name: "LinkTopologiesLen".into(), value: "1".into(), typ: "u32".into() }, FieldValue { name: "LinkTopologies0".into(), value: pubkey_bs58(&topology_pk), typ: "pubkey".into() }, - FieldValue { name: "UnicastDrained".into(), value: "true".into(), typ: "bool".into() }, + FieldValue { name: "LinkFlags".into(), value: "1".into(), typ: "u8".into() }, ], }; diff --git a/sdk/serviceability/testdata/fixtures/link.json b/sdk/serviceability/testdata/fixtures/link.json index b5ca99ef35..021c5f9433 100644 --- a/sdk/serviceability/testdata/fixtures/link.json +++ b/sdk/serviceability/testdata/fixtures/link.json @@ -118,9 +118,9 @@ "typ": "pubkey" }, { - "name": "UnicastDrained", - "value": "true", - "typ": "bool" + "name": "LinkFlags", + "value": "1", + "typ": "u8" } ] } \ No newline at end of file diff --git a/sdk/telemetry/testdata/fixtures/generate-fixtures/Cargo.lock b/sdk/telemetry/testdata/fixtures/generate-fixtures/Cargo.lock index 2352096a76..6f20823fdc 100644 --- a/sdk/telemetry/testdata/fixtures/generate-fixtures/Cargo.lock +++ b/sdk/telemetry/testdata/fixtures/generate-fixtures/Cargo.lock @@ -582,7 +582,7 @@ dependencies = [ [[package]] name = "doublezero-config" -version = "0.10.0" +version = "0.15.0" dependencies = [ "eyre", "serde", @@ -591,7 +591,7 @@ dependencies = [ [[package]] name = "doublezero-program-common" -version = "0.10.0" +version = "0.15.0" dependencies = [ "borsh 1.6.0", "byteorder", @@ -603,7 +603,7 @@ dependencies = [ [[package]] name = "doublezero-serviceability" -version = "0.10.0" +version = "0.15.0" dependencies = [ "bitflags", "borsh 1.6.0", @@ -618,7 +618,7 @@ dependencies = [ [[package]] name = "doublezero-telemetry" -version = "0.10.0" +version = "0.15.0" dependencies = [ "borsh 1.6.0", "borsh-incremental", diff --git a/smartcontract/cli/src/link/accept.rs b/smartcontract/cli/src/link/accept.rs index d5515de747..f5804cd693 100644 --- a/smartcontract/cli/src/link/accept.rs +++ b/smartcontract/cli/src/link/accept.rs @@ -239,7 +239,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -253,7 +253,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; client diff --git a/smartcontract/cli/src/link/delete.rs b/smartcontract/cli/src/link/delete.rs index 1bc7a1c288..f39a069512 100644 --- a/smartcontract/cli/src/link/delete.rs +++ b/smartcontract/cli/src/link/delete.rs @@ -139,7 +139,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -153,7 +153,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; client diff --git a/smartcontract/cli/src/link/dzx_create.rs b/smartcontract/cli/src/link/dzx_create.rs index 9c22168ce3..252b228fb4 100644 --- a/smartcontract/cli/src/link/dzx_create.rs +++ b/smartcontract/cli/src/link/dzx_create.rs @@ -5,7 +5,7 @@ use crate::{ requirements::{CHECK_BALANCE, CHECK_ID_JSON}, validators::{ validate_code, validate_parse_bandwidth, validate_parse_delay_ms, validate_parse_jitter_ms, - validate_pubkey_or_code, + validate_parse_mtu, validate_pubkey_or_code, }, }; use clap::Args; @@ -48,8 +48,8 @@ pub struct CreateDZXLinkCliCommand { /// Bandwidth (required). Accepts values in Kbps, Mbps, or Gbps. #[arg(long, value_parser = validate_parse_bandwidth)] pub bandwidth: u64, - /// MTU (Maximum Transmission Unit) in bytes. Must be 9000. - #[arg(long, default_value_t = 9000)] + /// MTU (Maximum Transmission Unit) in bytes. + #[arg(long, value_parser = validate_parse_mtu)] pub mtu: u32, /// RTT (Round Trip Time) delay in milliseconds. #[arg(long, value_parser = validate_parse_delay_ms)] @@ -126,17 +126,13 @@ impl CreateDZXLinkCliCommand { )); } - if side_a_iface.mtu != 9000 { + if side_a_iface.mtu != 2048 { return Err(eyre!( - "Interface '{}' on side A device has MTU {} but DZX link interfaces must have MTU 9000", + "Interface '{}' on side A device has MTU {} but DZX link interfaces must have MTU 2048", self.side_a_interface, side_a_iface.mtu )); } - if self.mtu != 9000 { - return Err(eyre!("Link MTU must be 9000")); - } - if client .get_link(GetLinkCommand { pubkey_or_code: self.code.clone(), @@ -234,7 +230,7 @@ mod tests { ip_net: "10.2.0.1/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 9000, + mtu: 2048, ..Default::default() } .to_interface()], @@ -279,7 +275,7 @@ mod tests { ip_net: "10.2.0.2/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 9000, + mtu: 2048, ..Default::default() } .to_interface()], @@ -324,7 +320,7 @@ mod tests { ip_net: "10.2.0.3/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 9000, + mtu: 2048, ..Default::default() } .to_interface()], @@ -350,7 +346,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -365,7 +361,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; client @@ -407,7 +403,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::DZX, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, side_a_iface_name: "Ethernet1/1".to_string(), @@ -425,7 +421,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -453,7 +449,7 @@ mod tests { side_a: device2_pk.to_string(), side_z: device3_pk.to_string(), bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/2".to_string(), @@ -556,7 +552,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -657,7 +653,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 9000, + mtu: 2048, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -668,7 +664,7 @@ mod tests { assert!(res.is_err()); assert_eq!( res.unwrap_err().to_string(), - "Interface 'Ethernet1/1' on side A device has MTU 1500 but DZX link interfaces must have MTU 9000" + "Interface 'Ethernet1/1' on side A device has MTU 1500 but DZX link interfaces must have MTU 2048" ); } } diff --git a/smartcontract/cli/src/link/get.rs b/smartcontract/cli/src/link/get.rs index 36b04f59d8..3f889034ed 100644 --- a/smartcontract/cli/src/link/get.rs +++ b/smartcontract/cli/src/link/get.rs @@ -119,7 +119,9 @@ impl GetLinkCliCommand { health: link.link_health.to_string(), owner: link.owner.to_string(), link_topologies: resolve_topology_names(&link.link_topologies, &topology_map), - unicast_drained: link.unicast_drained, + unicast_drained: link.link_flags + & doublezero_serviceability::state::link::LINK_FLAG_UNICAST_DRAINED + != 0, }; if self.json { @@ -175,7 +177,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -189,7 +191,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let contributor = Contributor { @@ -328,7 +330,7 @@ mod tests { assert_eq!(json["status"].as_str().unwrap(), "activated"); assert_eq!(json["tunnel_type"].as_str().unwrap(), "WAN"); assert_eq!(json["bandwidth"].as_u64().unwrap(), 1_000_000_000); - assert_eq!(json["mtu"].as_u64().unwrap(), 9000); + assert_eq!(json["mtu"].as_u64().unwrap(), 1500); assert_eq!(json["contributor"].as_str().unwrap(), "test-contributor"); assert_eq!(json["side_a"].as_str().unwrap(), "side-a-device"); assert_eq!(json["side_z"].as_str().unwrap(), "side-z-device"); diff --git a/smartcontract/cli/src/link/latency.rs b/smartcontract/cli/src/link/latency.rs index bb5c936b81..963e43478a 100644 --- a/smartcontract/cli/src/link/latency.rs +++ b/smartcontract/cli/src/link/latency.rs @@ -178,7 +178,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, tunnel_id: 1, @@ -191,7 +191,7 @@ mod tests { link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, } } diff --git a/smartcontract/cli/src/link/list.rs b/smartcontract/cli/src/link/list.rs index 623dc91d5e..e9e8b6bdce 100644 --- a/smartcontract/cli/src/link/list.rs +++ b/smartcontract/cli/src/link/list.rs @@ -42,6 +42,9 @@ pub struct ListLinkCliCommand { /// Filter by link code (partial match) #[arg(long)] pub code: Option, + /// Filter by topology name (use "default" for links with no topology assignment) + #[arg(long)] + pub topology: Option, /// List only WAN links. #[arg(long, default_value_t = false)] pub wan: bool, @@ -205,6 +208,20 @@ impl ListLinkCliCommand { links.retain(|(_, link)| link.code.contains(code_filter)); } + // Filter by topology if specified + if let Some(topology_filter) = &self.topology { + if topology_filter == "default" { + links.retain(|(_, link)| link.link_topologies.is_empty()); + } else { + let topology_pk = topology_map + .iter() + .find(|(_, t)| t.name == *topology_filter) + .map(|(pk, _)| *pk) + .ok_or_else(|| eyre::eyre!("Topology '{}' not found", topology_filter))?; + links.retain(|(_, link)| link.link_topologies.contains(&topology_pk)); + } + } + let mut tunnel_displays: Vec = links .into_iter() .map(|(pubkey, link)| { @@ -244,7 +261,9 @@ impl ListLinkCliCommand { health: link.link_health, owner: link.owner, link_topologies: resolve_topology_names(&link.link_topologies, &topology_map), - unicast_drained: link.unicast_drained, + unicast_drained: link.link_flags + & doublezero_serviceability::state::link::LINK_FLAG_UNICAST_DRAINED + != 0, } }) .collect(); @@ -407,7 +426,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; client.expect_list_link().returning(move |_| { @@ -429,6 +448,7 @@ mod tests { health: None, desired_status: None, code: None, + topology: None, wan: false, dzx: false, json: false, @@ -450,6 +470,7 @@ mod tests { health: None, desired_status: None, code: None, + topology: None, wan: false, dzx: false, json: false, @@ -607,7 +628,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let tunnel2_pubkey = Pubkey::new_unique(); let tunnel2 = Link { @@ -620,7 +641,7 @@ mod tests { side_z_pk: device1_pubkey, link_type: LinkLinkType::WAN, bandwidth: 5_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 40_000, jitter_ns: 2000, delay_override_ns: 0, @@ -634,7 +655,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; client.expect_list_link().returning(move |_| { @@ -657,6 +678,7 @@ mod tests { health: None, desired_status: None, code: None, + topology: None, wan: false, dzx: false, json: false, @@ -788,7 +810,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -802,7 +824,7 @@ mod tests { side_z_pk: device2_pubkey, link_type: LinkLinkType::DZX, bandwidth: 5_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 10_000, jitter_ns: 500, delay_override_ns: 0, @@ -816,7 +838,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; client.expect_list_link().returning(move |_| { @@ -840,6 +862,7 @@ mod tests { health: None, desired_status: None, code: None, + topology: None, wan: false, dzx: false, json: false, @@ -970,7 +993,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -984,7 +1007,7 @@ mod tests { side_z_pk: device1_pubkey, link_type: LinkLinkType::WAN, bandwidth: 5_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 10_000, jitter_ns: 500, delay_override_ns: 0, @@ -998,7 +1021,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; client.expect_list_link().returning(move |_| { @@ -1022,6 +1045,7 @@ mod tests { health: None, desired_status: None, code: None, + topology: None, wan: false, dzx: false, json: false, @@ -1119,7 +1143,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -1133,7 +1157,7 @@ mod tests { side_z_pk: device1_pubkey, link_type: LinkLinkType::WAN, bandwidth: 5_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 10_000, jitter_ns: 500, delay_override_ns: 0, @@ -1147,7 +1171,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; client.expect_list_link().returning(move |_| { @@ -1171,6 +1195,7 @@ mod tests { health: None, desired_status: None, code: Some("production".to_string()), + topology: None, wan: false, dzx: false, json: false, diff --git a/smartcontract/cli/src/link/sethealth.rs b/smartcontract/cli/src/link/sethealth.rs index 24611fd3d1..62ec39956f 100644 --- a/smartcontract/cli/src/link/sethealth.rs +++ b/smartcontract/cli/src/link/sethealth.rs @@ -93,7 +93,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -107,7 +107,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let link2 = Link { @@ -120,7 +120,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -134,7 +134,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; client From c376b630c3765b55f2bfd11852b9d57f7d150393 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 6 Apr 2026 14:29:55 -0500 Subject: [PATCH 39/49] smartcontract: link_flags bitmask, update validation, topology PDA checks, topology_test mtu fixes --- smartcontract/cli/src/link/update.rs | 28 +- smartcontract/cli/src/link/wan_create.rs | 42 ++- smartcontract/cli/src/topology/clear.rs | 196 +++++++++-- smartcontract/cli/src/topology/delete.rs | 2 +- smartcontract/cli/src/topology/list.rs | 2 +- .../src/processors/link/create.rs | 8 +- .../src/processors/link/update.rs | 14 +- .../src/processors/resource/mod.rs | 11 +- .../src/processors/topology/backfill.rs | 44 ++- .../src/processors/topology/create.rs | 40 +-- .../src/processors/topology/delete.rs | 6 +- .../src/state/interface.rs | 142 +------- .../src/state/link.rs | 85 ++++- .../tests/delete_cyoa_interface_test.rs | 11 +- .../tests/link_wan_test.rs | 82 +---- .../tests/topology_test.rs | 319 ++++++++++++++++-- ...initialize_device_latency_samples_tests.rs | 16 +- .../sdk/go/serviceability/deserialize.go | 40 ++- smartcontract/sdk/go/serviceability/state.go | 41 ++- .../sdk/go/serviceability/state_test.go | 9 +- .../sdk/rs/src/commands/contributor/create.rs | 1 + .../sdk/rs/src/commands/link/accept.rs | 4 +- .../sdk/rs/src/commands/link/activate.rs | 4 +- .../sdk/rs/src/commands/link/closeaccount.rs | 4 +- .../sdk/rs/src/commands/link/delete.rs | 2 +- .../sdk/rs/src/commands/topology/create.rs | 16 +- smartcontract/test/start-test.sh | 50 ++- 27 files changed, 795 insertions(+), 424 deletions(-) diff --git a/smartcontract/cli/src/link/update.rs b/smartcontract/cli/src/link/update.rs index 1789e93210..9e25fe3769 100644 --- a/smartcontract/cli/src/link/update.rs +++ b/smartcontract/cli/src/link/update.rs @@ -4,8 +4,8 @@ use crate::{ requirements::{CHECK_BALANCE, CHECK_ID_JSON}, validators::{ validate_code, validate_parse_bandwidth, validate_parse_delay_ms, - validate_parse_delay_override_ms, validate_parse_jitter_ms, validate_pubkey, - validate_pubkey_or_code, + validate_parse_delay_override_ms, validate_parse_jitter_ms, validate_parse_mtu, + validate_pubkey, validate_pubkey_or_code, }, }; use clap::Args; @@ -35,8 +35,8 @@ pub struct UpdateLinkCliCommand { /// Updated bandwidth (e.g. 1Gbps, 100Mbps) #[arg(long, value_parser = validate_parse_bandwidth)] pub bandwidth: Option, - /// Updated MTU (Maximum Transmission Unit) in bytes. Must be 9000. - #[arg(long)] + /// Updated MTU (Maximum Transmission Unit) in bytes + #[arg(long, value_parser = validate_parse_mtu)] pub mtu: Option, /// RTT (Round Trip Time) delay in milliseconds #[arg(long, value_parser = validate_parse_delay_ms)] @@ -103,12 +103,6 @@ impl UpdateLinkCliCommand { .transpose() .map_err(|e| eyre!("Invalid status: {e}"))?; - if let Some(mtu) = self.mtu { - if mtu != 9000 { - return Err(eyre!("Link MTU must be 9000")); - } - } - if let Some(ref code) = self.code { if link.code != *code && client @@ -223,7 +217,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -237,7 +231,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let link2 = Link { @@ -250,7 +244,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -264,7 +258,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; client @@ -303,7 +297,7 @@ mod tests { contributor_pk: Some(contributor_pk), tunnel_type: None, bandwidth: Some(1000000000), - mtu: Some(9000), + mtu: Some(1500), delay_ns: Some(10000000), jitter_ns: Some(5000000), delay_override_ns: None, @@ -324,7 +318,7 @@ mod tests { contributor: Some(contributor_pk.to_string()), tunnel_type: None, bandwidth: Some(1000000000), - mtu: Some(9000), + mtu: Some(1500), delay_ms: Some(10.0), jitter_ms: Some(5.0), delay_override_ms: None, @@ -351,7 +345,7 @@ mod tests { contributor: Some(contributor_pk.to_string()), tunnel_type: None, bandwidth: Some(1000000000), - mtu: Some(9000), + mtu: Some(1500), delay_ms: Some(10.0), jitter_ms: Some(5.0), delay_override_ms: None, diff --git a/smartcontract/cli/src/link/wan_create.rs b/smartcontract/cli/src/link/wan_create.rs index e7e44cc74f..a443f626a8 100644 --- a/smartcontract/cli/src/link/wan_create.rs +++ b/smartcontract/cli/src/link/wan_create.rs @@ -5,7 +5,7 @@ use crate::{ requirements::{CHECK_BALANCE, CHECK_ID_JSON}, validators::{ validate_code, validate_parse_bandwidth, validate_parse_delay_ms, validate_parse_jitter_ms, - validate_pubkey_or_code, + validate_parse_mtu, validate_pubkey_or_code, }, }; use clap::Args; @@ -51,8 +51,8 @@ pub struct CreateWANLinkCliCommand { /// Bandwidth (required). Accepts values in Kbps, Mbps, or Gbps. #[arg(long, value_parser = validate_parse_bandwidth)] pub bandwidth: u64, - /// MTU (Maximum Transmission Unit) in bytes. Must be 9000. - #[arg(long, default_value_t = 9000)] + /// MTU (Maximum Transmission Unit) in bytes. + #[arg(long, value_parser = validate_parse_mtu)] pub mtu: u32, /// RTT (Round Trip Time) delay in milliseconds. #[arg(long, value_parser = validate_parse_delay_ms)] @@ -129,9 +129,9 @@ impl CreateWANLinkCliCommand { )); } - if side_a_iface.mtu != 9000 { + if side_a_iface.mtu != 2048 { return Err(eyre!( - "Interface '{}' on side A device has MTU {} but WAN link interfaces must have MTU 9000", + "Interface '{}' on side A device has MTU {} but WAN link interfaces must have MTU 2048", self.side_a_interface, side_a_iface.mtu )); } @@ -171,17 +171,13 @@ impl CreateWANLinkCliCommand { )); } - if side_z_iface.mtu != 9000 { + if side_z_iface.mtu != 2048 { return Err(eyre!( - "Interface '{}' on side Z device has MTU {} but WAN link interfaces must have MTU 9000", + "Interface '{}' on side Z device has MTU {} but WAN link interfaces must have MTU 2048", self.side_z_interface, side_z_iface.mtu )); } - if self.mtu != 9000 { - return Err(eyre!("Link MTU must be 9000")); - } - if client .get_link(GetLinkCommand { pubkey_or_code: self.code.clone(), @@ -281,7 +277,7 @@ mod tests { ip_net: "10.2.0.1/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 9000, + mtu: 2048, ..Default::default() } .to_interface()], @@ -326,7 +322,7 @@ mod tests { ip_net: "10.2.0.2/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 9000, + mtu: 2048, ..Default::default() } .to_interface()], @@ -371,7 +367,7 @@ mod tests { ip_net: "10.2.0.3/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 9000, + mtu: 2048, ..Default::default() } .to_interface()], @@ -397,7 +393,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -412,7 +408,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; client @@ -454,7 +450,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, side_a_iface_name: "Ethernet1/1".to_string(), @@ -472,7 +468,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -501,7 +497,7 @@ mod tests { side_a: device2_pk.to_string(), side_z: device3_pk.to_string(), bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/2".to_string(), @@ -639,7 +635,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -727,7 +723,7 @@ mod tests { name: "Ethernet1/2".to_string(), interface_type: InterfaceType::Physical, loopback_type: LoopbackType::None, - mtu: 9000, + mtu: 2048, vlan_id: 16, ip_net: "10.2.0.2/24".parse().unwrap(), node_segment_idx: 0, @@ -774,7 +770,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 9000, + mtu: 2048, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -786,7 +782,7 @@ mod tests { assert!(res.is_err()); assert_eq!( res.unwrap_err().to_string(), - "Interface 'Ethernet1/1' on side A device has MTU 1500 but WAN link interfaces must have MTU 9000" + "Interface 'Ethernet1/1' on side A device has MTU 1500 but WAN link interfaces must have MTU 2048" ); } } diff --git a/smartcontract/cli/src/topology/clear.rs b/smartcontract/cli/src/topology/clear.rs index 81a925a614..928ce995c9 100644 --- a/smartcontract/cli/src/topology/clear.rs +++ b/smartcontract/cli/src/topology/clear.rs @@ -7,12 +7,17 @@ use doublezero_sdk::commands::topology::clear::ClearTopologyCommand; use solana_sdk::pubkey::Pubkey; use std::io::Write; +// Solana transactions have a 32-account limit. With 3 fixed accounts (topology PDA, +// globalstate, payer), we can fit at most 29 link accounts per transaction. +const CLEAR_BATCH_SIZE: usize = 29; + #[derive(Args, Debug)] pub struct ClearTopologyCliCommand { /// Name of the topology to clear from links #[arg(long)] pub name: String, - /// Comma-separated list of link pubkeys to clear the topology from + /// Comma-separated list of link pubkeys to clear the topology from. + /// If omitted, all links currently tagged with this topology are discovered automatically. #[arg(long, value_delimiter = ',')] pub links: Vec, } @@ -21,21 +26,56 @@ impl ClearTopologyCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; - let link_pubkeys: Vec = self - .links - .iter() - .map(|s| { - s.parse::() - .map_err(|_| eyre::eyre!("invalid link pubkey: {}", s)) - }) - .collect::>>()?; + let link_pubkeys: Vec = if self.links.is_empty() { + // Auto-discover: find all links tagged with this topology. + let topology_map = client + .list_topology(doublezero_sdk::commands::topology::list::ListTopologyCommand)?; + let topology_pk = topology_map + .iter() + .find(|(_, t)| t.name == self.name) + .map(|(pk, _)| *pk) + .ok_or_else(|| eyre::eyre!("Topology '{}' not found", self.name))?; + + let links = client.list_link(doublezero_sdk::commands::link::list::ListLinkCommand)?; + links + .into_iter() + .filter(|(_, link)| link.link_topologies.contains(&topology_pk)) + .map(|(pk, _)| pk) + .collect() + } else { + self.links + .iter() + .map(|s| { + s.parse::() + .map_err(|_| eyre::eyre!("invalid link pubkey: {}", s)) + }) + .collect::>>()? + }; + + let total = link_pubkeys.len(); + + if total == 0 { + writeln!( + out, + "No links tagged with topology '{}'. Nothing to clear.", + self.name + )?; + return Ok(()); + } - let n = link_pubkeys.len(); - client.clear_topology(ClearTopologyCommand { - name: self.name.clone(), - link_pubkeys, - })?; - writeln!(out, "Cleared topology '{}' from {} link(s).", self.name, n)?; + // Batch into chunks that fit within Solana's account limit. + for chunk in link_pubkeys.chunks(CLEAR_BATCH_SIZE) { + client.clear_topology(ClearTopologyCommand { + name: self.name.clone(), + link_pubkeys: chunk.to_vec(), + })?; + } + + writeln!( + out, + "Cleared topology '{}' from {} link(s).", + self.name, total + )?; Ok(()) } @@ -45,21 +85,36 @@ impl ClearTopologyCliCommand { mod tests { use super::*; use crate::doublezerocommand::MockCliCommand; + use doublezero_sdk::{Link, TopologyInfo}; use mockall::predicate::eq; use solana_sdk::{pubkey::Pubkey, signature::Signature}; - use std::io::Cursor; + use std::{collections::HashMap, io::Cursor}; #[test] - fn test_clear_topology_execute_no_links() { + fn test_clear_topology_execute_no_links_auto_discover_empty() { + // When links is empty, auto-discovery runs but finds no tagged links. let mut mock = MockCliCommand::new(); + let topology_pk = Pubkey::new_unique(); - mock.expect_check_requirements().returning(|_| Ok(())); - mock.expect_clear_topology() - .with(eq(ClearTopologyCommand { + let mut topology_map: HashMap = HashMap::new(); + topology_map.insert( + topology_pk, + TopologyInfo { + account_type: doublezero_sdk::AccountType::Topology, + owner: Pubkey::default(), + bump_seed: 0, name: "unicast-default".to_string(), - link_pubkeys: vec![], - })) - .returning(|_| Ok(Signature::new_unique())); + admin_group_bit: 1, + flex_algo_number: 129, + constraint: + doublezero_serviceability::state::topology::TopologyConstraint::IncludeAny, + }, + ); + + mock.expect_check_requirements().returning(|_| Ok(())); + mock.expect_list_topology() + .returning(move |_| Ok(topology_map.clone())); + mock.expect_list_link().returning(|_| Ok(HashMap::new())); let cmd = ClearTopologyCliCommand { name: "unicast-default".to_string(), @@ -69,7 +124,7 @@ mod tests { let result = cmd.execute(&mock, &mut out); assert!(result.is_ok()); let output = String::from_utf8(out.into_inner()).unwrap(); - assert!(output.contains("Cleared topology 'unicast-default' from 0 link(s).")); + assert!(output.contains("No links tagged with topology 'unicast-default'.")); } #[test] @@ -111,4 +166,97 @@ mod tests { let result = cmd.execute(&mock, &mut out); assert!(result.is_err()); } + + #[test] + fn test_clear_topology_auto_discover() { + let mut mock = MockCliCommand::new(); + let topology_pk = Pubkey::new_unique(); + let other_topology_pk = Pubkey::new_unique(); + let link1_pk = Pubkey::new_unique(); + let link2_pk = Pubkey::new_unique(); + let untagged_pk = Pubkey::new_unique(); + + let mut topology_map: HashMap = HashMap::new(); + topology_map.insert( + topology_pk, + TopologyInfo { + account_type: doublezero_sdk::AccountType::Topology, + owner: Pubkey::default(), + bump_seed: 0, + name: "my-topo".to_string(), + admin_group_bit: 1, + flex_algo_number: 129, + constraint: + doublezero_serviceability::state::topology::TopologyConstraint::IncludeAny, + }, + ); + + let mut links: HashMap = HashMap::new(); + links.insert( + link1_pk, + Link { + link_topologies: vec![topology_pk], + ..Default::default() + }, + ); + links.insert( + link2_pk, + Link { + link_topologies: vec![topology_pk, other_topology_pk], + ..Default::default() + }, + ); + links.insert( + untagged_pk, + Link { + link_topologies: vec![], + ..Default::default() + }, + ); + + mock.expect_check_requirements().returning(|_| Ok(())); + mock.expect_list_topology() + .returning(move |_| Ok(topology_map.clone())); + mock.expect_list_link() + .returning(move |_| Ok(links.clone())); + mock.expect_clear_topology() + .withf(move |cmd| { + cmd.name == "my-topo" + && cmd.link_pubkeys.len() == 2 + && cmd.link_pubkeys.contains(&link1_pk) + && cmd.link_pubkeys.contains(&link2_pk) + }) + .returning(|_| Ok(Signature::new_unique())); + + let cmd = ClearTopologyCliCommand { + name: "my-topo".to_string(), + links: vec![], + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_ok(), "{:?}", result); + let output = String::from_utf8(out.into_inner()).unwrap(); + assert!(output.contains("Cleared topology 'my-topo' from 2 link(s).")); + } + + #[test] + fn test_clear_topology_auto_discover_not_found() { + let mut mock = MockCliCommand::new(); + + mock.expect_check_requirements().returning(|_| Ok(())); + mock.expect_list_topology() + .returning(|_| Ok(HashMap::new())); + + let cmd = ClearTopologyCliCommand { + name: "nonexistent".to_string(), + links: vec![], + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Topology 'nonexistent' not found")); + } } diff --git a/smartcontract/cli/src/topology/delete.rs b/smartcontract/cli/src/topology/delete.rs index 487f4d23f0..d92cd7a661 100644 --- a/smartcontract/cli/src/topology/delete.rs +++ b/smartcontract/cli/src/topology/delete.rs @@ -118,7 +118,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: vec![topology_pda], - unicast_drained: false, + link_flags: 0, }; client.expect_list_link().returning(move |_| { diff --git a/smartcontract/cli/src/topology/list.rs b/smartcontract/cli/src/topology/list.rs index 9f67f462a3..fa57dcccf0 100644 --- a/smartcontract/cli/src/topology/list.rs +++ b/smartcontract/cli/src/topology/list.rs @@ -199,7 +199,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: vec![topology_pda], - unicast_drained: false, + link_flags: 0, }; client.expect_list_link().returning(move |_| { diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs index 001d652463..395e7f3d8e 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs @@ -8,7 +8,7 @@ use crate::{ contributor::Contributor, device::Device, globalstate::GlobalState, - interface::{InterfaceCYOA, InterfaceDIA, InterfaceStatus, LINK_MTU}, + interface::{InterfaceCYOA, InterfaceDIA, InterfaceStatus}, link::*, }, }; @@ -190,10 +190,6 @@ pub fn process_create_link( return Err(DoubleZeroError::InvalidInterfaceZForExternal.into()); } - if value.mtu != LINK_MTU { - return Err(DoubleZeroError::InvalidMtu.into()); - } - let status = if value.link_type == LinkLinkType::DZX { LinkStatus::Requested } else { @@ -229,7 +225,7 @@ pub fn process_create_link( link_health: LinkHealth::ReadyForService, // Force the link to be ready for service until the health oracle is implemented, desired_status: value.desired_status.unwrap_or(LinkDesiredStatus::Activated), link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; link.check_status_transition(); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs index 2fb0ac957f..e93d0f0749 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs @@ -1,5 +1,5 @@ use crate::{ - error::DoubleZeroError, + error::{DoubleZeroError, Validate}, pda::get_resource_extension_pda, processors::{ resource::{allocate_specific_id, allocate_specific_ip, deallocate_id, deallocate_ip}, @@ -228,9 +228,6 @@ pub fn process_update_link( link.bandwidth = bandwidth; } if let Some(mtu) = value.mtu { - if mtu != crate::state::interface::LINK_MTU { - return Err(DoubleZeroError::InvalidMtu.into()); - } link.mtu = mtu; } if let Some(delay_ns) = value.delay_ns { @@ -382,7 +379,7 @@ pub fn process_update_link( link.link_topologies = link_topologies.clone(); } - // unicast_drained: contributor A or foundation + // unicast_drained (LINK_FLAG_UNICAST_DRAINED bit 0): contributor A or foundation if let Some(unicast_drained) = value.unicast_drained { if link.contributor_pk != *contributor_account.key && !globalstate.foundation_allowlist.contains(payer_account.key) @@ -390,10 +387,15 @@ pub fn process_update_link( msg!("unicast_drained update requires contributor A or foundation allowlist"); return Err(DoubleZeroError::NotAllowed.into()); } - link.unicast_drained = unicast_drained; + if unicast_drained { + link.link_flags |= crate::state::link::LINK_FLAG_UNICAST_DRAINED; + } else { + link.link_flags &= !crate::state::link::LINK_FLAG_UNICAST_DRAINED; + } } link.check_status_transition(); + link.validate()?; try_acc_write(&link, link_account, payer_account, accounts)?; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs index 8f9dc19a1f..565171f171 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs @@ -69,7 +69,7 @@ pub fn get_resource_extension_range( ResourceType::LinkIds => ResourceExtensionRange::IdRange(0, 65535), ResourceType::SegmentRoutingIds => ResourceExtensionRange::IdRange(1, 65535), ResourceType::VrfIds => ResourceExtensionRange::IdRange(1, 1024), - ResourceType::AdminGroupBits => ResourceExtensionRange::IdRange(0, 127), + ResourceType::AdminGroupBits => ResourceExtensionRange::IdRange(1, 127), } } @@ -172,15 +172,6 @@ pub fn create_resource( resource.allocate(1)?; // Allocates index 0 } - // Pre-mark bit 1 (UNICAST-DRAINED) so it is never allocated to a user topology. - // IS-IS flex-algo admin-group bit 1 is reserved for the UNICAST-DRAINED topology - // and must never be reused. - if let ResourceType::AdminGroupBits = resource_type { - let mut buffer = resource_account.data.borrow_mut(); - let mut resource = ResourceExtensionBorrowed::inplace_from(&mut buffer[..])?; - resource.allocate_specific(&crate::resource::IdOrIp::Id(1))?; - } - Ok(()) } diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs index 33452554b6..4bef8b0176 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs @@ -1,7 +1,7 @@ use crate::{ error::DoubleZeroError, pda::{get_resource_extension_pda, get_topology_pda}, - processors::resource::allocate_id, + processors::resource::{allocate_id, allocate_specific_id}, resource::ResourceType, serializer::try_acc_write, state::{ @@ -88,7 +88,35 @@ pub fn process_topology_backfill( let mut backfilled_count: usize = 0; let mut skipped_count: usize = 0; - for device_account in accounts_iter { + // Collect device accounts for two-pass processing. + let device_accounts: Vec<&AccountInfo> = accounts_iter.collect(); + + // First pass: pre-mark all existing node_segment_idx values as used in the + // SegmentRoutingIds resource. This prevents collisions when the activator + // manages SR IDs in-memory (use_onchain_allocation=false) and the on-chain + // resource hasn't been updated to reflect those allocations. + for device_account in &device_accounts { + if device_account.owner != program_id { + continue; + } + let device = match Device::try_from(&device_account.data.borrow()[..]) { + Ok(d) => d, + Err(_) => continue, + }; + for iface in device.interfaces.iter() { + let current = iface.into_current_version(); + if current.node_segment_idx > 0 { + // Ignore error: ID may already be marked (idempotent pre-mark). + let _ = allocate_specific_id(segment_routing_ids_account, current.node_segment_idx); + } + for fas in ¤t.flex_algo_node_segments { + let _ = allocate_specific_id(segment_routing_ids_account, fas.node_segment_idx); + } + } + } + + // Second pass: allocate new IDs for loopbacks missing this topology's segment. + for device_account in &device_accounts { if device_account.owner != program_id { continue; } @@ -98,12 +126,12 @@ pub fn process_topology_backfill( }; let mut modified = false; for iface in device.interfaces.iter_mut() { - let iface_v3 = iface.into_current_version(); - if iface_v3.loopback_type != LoopbackType::Vpnv4 { + let iface_v2 = iface.into_current_version(); + if iface_v2.loopback_type != LoopbackType::Vpnv4 { continue; } // Skip if already has a segment for this topology (idempotent) - if iface_v3 + if iface_v2 .flex_algo_node_segments .iter() .any(|s| &s.topology == topology_key) @@ -113,8 +141,8 @@ pub fn process_topology_backfill( } let node_segment_idx = allocate_id(segment_routing_ids_account)?; match iface { - Interface::V3(ref mut v3) => { - v3.flex_algo_node_segments.push(FlexAlgoNodeSegment { + Interface::V2(ref mut v2) => { + v2.flex_algo_node_segments.push(FlexAlgoNodeSegment { topology: *topology_key, node_segment_idx, }); @@ -125,7 +153,7 @@ pub fn process_topology_backfill( topology: *topology_key, node_segment_idx, }); - *iface = Interface::V3(upgraded); + *iface = Interface::V2(upgraded); } } modified = true; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs index 48c7263cee..82035c216a 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs @@ -1,7 +1,7 @@ use crate::{ error::DoubleZeroError, - pda::get_topology_pda, - processors::resource::allocate_id, + pda::{get_resource_extension_pda, get_topology_pda}, + processors::{resource::allocate_id, validation::validate_program_account}, resource::ResourceType, seeds::{SEED_PREFIX, SEED_TOPOLOGY}, serializer::{try_acc_create, try_acc_write}, @@ -85,18 +85,18 @@ pub fn process_topology_create( } // Validate AdminGroupBits resource account - assert_eq!( - admin_group_bits_account.owner, program_id, - "TopologyCreate: invalid AdminGroupBits account owner" + let (expected_ab_pda, _, _) = + get_resource_extension_pda(program_id, ResourceType::AdminGroupBits); + validate_program_account!( + admin_group_bits_account, + program_id, + writable = true, + pda = Some(&expected_ab_pda), + "AdminGroupBits" ); - // Allocate admin_group_bit (lowest available; bit 1 is pre-marked = never returned) - let admin_group_bit_u16 = allocate_id(admin_group_bits_account)?; - if admin_group_bit_u16 > 127 { - msg!("TopologyCreate: AdminGroupBits exhausted (max 128 topologies)"); - return Err(DoubleZeroError::AllocationFailed.into()); - } - let admin_group_bit = admin_group_bit_u16 as u8; + // Allocate admin_group_bit (lowest available bit in IdRange) + let admin_group_bit = allocate_id(admin_group_bits_account)? as u8; let flex_algo_number = 128u8 .checked_add(admin_group_bit) .ok_or(DoubleZeroError::ArithmeticOverflow)?; @@ -149,12 +149,12 @@ pub fn process_topology_create( let mut device = Device::try_from(&device_account.data.borrow()[..])?; let mut modified = false; for iface in device.interfaces.iter_mut() { - let iface_v3 = iface.into_current_version(); - if iface_v3.loopback_type != LoopbackType::Vpnv4 { + let iface_v2 = iface.into_current_version(); + if iface_v2.loopback_type != LoopbackType::Vpnv4 { continue; } // Skip if already has a segment for this topology (idempotent) - if iface_v3 + if iface_v2 .flex_algo_node_segments .iter() .any(|s| &s.topology == topology_account.key) @@ -162,22 +162,22 @@ pub fn process_topology_create( continue; } let node_segment_idx = allocate_id(segment_routing_ids_account)?; - // Mutate the interface in place — we need to upgrade to V3 if needed + // Mutate the interface in place — upgrade to V2 if needed match iface { - Interface::V3(ref mut v3) => { - v3.flex_algo_node_segments.push(FlexAlgoNodeSegment { + Interface::V2(ref mut v2) => { + v2.flex_algo_node_segments.push(FlexAlgoNodeSegment { topology: *topology_account.key, node_segment_idx, }); } _ => { - // Upgrade to V3 with the segment added + // Upgrade to current version (V2) with the segment added let mut upgraded = iface.into_current_version(); upgraded.flex_algo_node_segments.push(FlexAlgoNodeSegment { topology: *topology_account.key, node_segment_idx, }); - *iface = Interface::V3(upgraded); + *iface = Interface::V2(upgraded); } } modified = true; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs index d42a37c534..e70e318237 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs @@ -76,7 +76,11 @@ pub fn process_topology_delete( } // Close the topology PDA (transfer lamports to payer, zero data) - // NOTE: We do NOT deallocate the admin-group bit — bits are permanently marked. + // NOTE: We do NOT deallocate the admin-group bit — bits are permanently retired. + // If a bit were reused for a new topology, any IS-IS router still advertising + // link memberships for the deleted topology would classify traffic onto the new + // topology's flex-algo path until the network fully converges, causing misrouting. + // Admin-group bits are a cheap resource (128 total), so permanent allocation is safe. try_acc_close(topology_account, payer_account)?; msg!("TopologyDelete: closed topology '{}'", value.name); diff --git a/smartcontract/programs/doublezero-serviceability/src/state/interface.rs b/smartcontract/programs/doublezero-serviceability/src/state/interface.rs index 9f21784ddd..c78143b6ca 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/interface.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/interface.rs @@ -4,10 +4,6 @@ use doublezero_program_common::{types::NetworkV4, validate_iface}; use solana_program::{msg, program_error::ProgramError}; use std::{fmt, str::FromStr}; -pub const LINK_MTU: u32 = 9000; -pub const INTERFACE_MTU: u16 = 9000; -pub const CYOA_DIA_INTERFACE_MTU: u16 = 1500; - #[repr(u8)] #[derive(BorshSerialize, BorshDeserialize, Debug, Copy, Clone, PartialEq, Default)] #[borsh(use_discriminant = true)] @@ -308,6 +304,7 @@ pub struct InterfaceV2 { pub ip_net: NetworkV4, // 4 IPv4 address + 1 subnet mask pub node_segment_idx: u16, // 2 pub user_tunnel_endpoint: bool, // 1 + pub flex_algo_node_segments: Vec, } impl InterfaceV2 { @@ -320,7 +317,7 @@ impl InterfaceV2 { } pub fn size_given_name_len(name_len: usize) -> usize { - 1 + 4 + name_len + 1 + 1 + 1 + 1 + 8 + 8 + 2 + 1 + 2 + 5 + 2 + 1 + 1 + 4 + name_len + 1 + 1 + 1 + 1 + 8 + 8 + 2 + 1 + 2 + 5 + 2 + 1 + 4 // +4 for empty flex_algo_node_segments vec (Borsh length prefix) } } @@ -346,6 +343,10 @@ impl TryFrom<&[u8]> for InterfaceV2 { let val: u8 = BorshDeserialize::deserialize(&mut data).unwrap_or_default(); val != 0 }, + // flex_algo_node_segments was added in the same version as this field set. + // Old on-chain V2 accounts (written before this field existed) will have no + // trailing bytes here — unwrap_or_default() yields an empty vec. + flex_algo_node_segments: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), }) } } @@ -363,113 +364,8 @@ impl TryFrom<&InterfaceV1> for InterfaceV2 { loopback_type: data.loopback_type, bandwidth: 0, cir: 0, - mtu: INTERFACE_MTU, - routing_mode: RoutingMode::Static, - vlan_id: data.vlan_id, - ip_net: data.ip_net, - node_segment_idx: data.node_segment_idx, - user_tunnel_endpoint: data.user_tunnel_endpoint, - }) - } -} - -impl Default for InterfaceV2 { - fn default() -> Self { - Self { - status: InterfaceStatus::Pending, - name: String::default(), - interface_type: InterfaceType::Invalid, - interface_cyoa: InterfaceCYOA::None, - interface_dia: InterfaceDIA::None, - loopback_type: LoopbackType::None, - bandwidth: 0, - cir: 0, - mtu: INTERFACE_MTU, + mtu: 1500, routing_mode: RoutingMode::Static, - vlan_id: 0, - ip_net: NetworkV4::default(), - node_segment_idx: 0, - user_tunnel_endpoint: false, - } - } -} - -#[derive(BorshDeserialize, BorshSerialize, Debug, PartialEq, Clone)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct InterfaceV3 { - pub status: InterfaceStatus, // 1 - pub name: String, // 4 + len - pub interface_type: InterfaceType, // 1 - pub interface_cyoa: InterfaceCYOA, // 1 - pub interface_dia: InterfaceDIA, // 1 - pub loopback_type: LoopbackType, // 1 - pub bandwidth: u64, // 8 - pub cir: u64, // 8 - pub mtu: u16, // 2 - pub routing_mode: RoutingMode, // 1 - pub vlan_id: u16, // 2 - pub ip_net: NetworkV4, // 4 IPv4 address + 1 subnet mask - pub node_segment_idx: u16, // 2 - pub user_tunnel_endpoint: bool, // 1 - pub flex_algo_node_segments: Vec, -} - -impl InterfaceV3 { - pub fn size(&self) -> usize { - Self::size_given_name_len(self.name.len()) - } - - pub fn to_interface(&self) -> Interface { - Interface::V3(self.clone()) - } - - pub fn size_given_name_len(name_len: usize) -> usize { - 1 + 4 + name_len + 1 + 1 + 1 + 1 + 8 + 8 + 2 + 1 + 2 + 5 + 2 + 1 + 4 // +4 for empty flex_algo_node_segments vec (Borsh length prefix) - } -} - -impl TryFrom<&[u8]> for InterfaceV3 { - type Error = ProgramError; - - fn try_from(mut data: &[u8]) -> Result { - Ok(Self { - status: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - name: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - interface_type: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - interface_cyoa: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - interface_dia: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - loopback_type: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - bandwidth: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - cir: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - mtu: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - routing_mode: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - vlan_id: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - ip_net: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - node_segment_idx: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - user_tunnel_endpoint: { - let val: u8 = BorshDeserialize::deserialize(&mut data).unwrap_or_default(); - val != 0 - }, - flex_algo_node_segments: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - }) - } -} - -impl TryFrom<&InterfaceV2> for InterfaceV3 { - type Error = ProgramError; - - fn try_from(data: &InterfaceV2) -> Result { - Ok(Self { - status: data.status, - name: data.name.clone(), - interface_type: data.interface_type, - interface_cyoa: data.interface_cyoa, - interface_dia: data.interface_dia, - loopback_type: data.loopback_type, - bandwidth: data.bandwidth, - cir: data.cir, - mtu: data.mtu, - routing_mode: data.routing_mode, vlan_id: data.vlan_id, ip_net: data.ip_net, node_segment_idx: data.node_segment_idx, @@ -479,7 +375,7 @@ impl TryFrom<&InterfaceV2> for InterfaceV3 { } } -impl Default for InterfaceV3 { +impl Default for InterfaceV2 { fn default() -> Self { Self { status: InterfaceStatus::Pending, @@ -508,20 +404,15 @@ impl Default for InterfaceV3 { pub enum Interface { V1(InterfaceV1), V2(InterfaceV2), - V3(InterfaceV3), } -pub type CurrentInterfaceVersion = InterfaceV3; +pub type CurrentInterfaceVersion = InterfaceV2; impl Interface { pub fn into_current_version(&self) -> CurrentInterfaceVersion { match self { - Interface::V1(v1) => { - let v2: InterfaceV2 = v1.try_into().unwrap_or_default(); - InterfaceV3::try_from(&v2).unwrap_or_default() - } - Interface::V2(v2) => InterfaceV3::try_from(v2).unwrap_or_default(), - Interface::V3(v3) => v3.clone(), + Interface::V1(v1) => v1.try_into().unwrap_or_default(), + Interface::V2(v2) => v2.clone(), } } @@ -529,7 +420,6 @@ impl Interface { let base_size = match self { Interface::V1(v1) => v1.size(), Interface::V2(v2) => v2.size(), - Interface::V3(v3) => v3.size(), }; base_size + 1 // +1 for the enum discriminant } @@ -594,9 +484,10 @@ impl TryFrom<&[u8]> for Interface { fn try_from(mut data: &[u8]) -> Result { match BorshDeserialize::deserialize(&mut data) { Ok(0u8) => Ok(Interface::V1(InterfaceV1::try_from(data)?)), - Ok(1u8) => Ok(Interface::V2(InterfaceV2::try_from(data)?)), - Ok(2u8) => Ok(Interface::V3(InterfaceV3::try_from(data)?)), - _ => Ok(Interface::V3(InterfaceV3::default())), + // Discriminant 1 = V2 (current). Discriminant 2 was the old V3 which has + // identical layout to V2 — treat it as V2 for backward compatibility. + Ok(1u8) | Ok(2u8) => Ok(Interface::V2(InterfaceV2::try_from(data)?)), + _ => Ok(Interface::V2(InterfaceV2::default())), } } } @@ -643,6 +534,7 @@ fn test_interface_version() { ip_net: "10.0.0.0/24".parse().unwrap(), node_segment_idx: 200, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface(); @@ -681,6 +573,7 @@ mod test_interface_validate { ip_net: NetworkV4::default(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } } @@ -786,6 +679,7 @@ mod test_interface_validate { ip_net: "203.0.113.40/32".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], }; // Serialize as Interface::V2 (with enum discriminant) diff --git a/smartcontract/programs/doublezero-serviceability/src/state/link.rs b/smartcontract/programs/doublezero-serviceability/src/state/link.rs index 95d726e48a..49371567cd 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/link.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/link.rs @@ -265,15 +265,19 @@ pub struct Link { pub link_health: LinkHealth, // 1 pub desired_status: LinkDesiredStatus, // 1 pub link_topologies: Vec, // 4 + 32 * len - pub unicast_drained: bool, // 1 + pub link_flags: u8, // 1 — bitmask; see LINK_FLAG_* constants } +/// Bit 0 of `link_flags`: link is administratively drained from unicast traffic. +/// Maps to IS-IS admin-group UNICAST-DRAINED (group 0). +pub const LINK_FLAG_UNICAST_DRAINED: u8 = 0x01; + impl fmt::Display for Link { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "account_type: {}, owner: {}, index: {}, side_a_pk: {}, side_z_pk: {}, tunnel_type: {}, bandwidth: {}, mtu: {}, delay_ns: {}, jitter_ns: {}, tunnel_id: {}, tunnel_net: {}, status: {}, code: {}, contributor_pk: {}, link_health: {}, desired_status: {}, link_topologies: {:?}, unicast_drained: {}", - self.account_type, self.owner, self.index, self.side_a_pk, self.side_z_pk, self.link_type, self.bandwidth, self.mtu, self.delay_ns, self.jitter_ns, self.tunnel_id, &self.tunnel_net, self.status, self.code, self.contributor_pk, self.link_health, self.desired_status, self.link_topologies, self.unicast_drained + "account_type: {}, owner: {}, index: {}, side_a_pk: {}, side_z_pk: {}, tunnel_type: {}, bandwidth: {}, mtu: {}, delay_ns: {}, jitter_ns: {}, tunnel_id: {}, tunnel_net: {}, status: {}, code: {}, contributor_pk: {}, link_health: {}, desired_status: {}, link_topologies: {:?}, link_flags: {:#04x}", + self.account_type, self.owner, self.index, self.side_a_pk, self.side_z_pk, self.link_type, self.bandwidth, self.mtu, self.delay_ns, self.jitter_ns, self.tunnel_id, &self.tunnel_net, self.status, self.code, self.contributor_pk, self.link_health, self.desired_status, self.link_topologies, self.link_flags ) } } @@ -303,7 +307,7 @@ impl Default for Link { link_health: LinkHealth::Pending, desired_status: LinkDesiredStatus::Pending, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, } } } @@ -335,7 +339,7 @@ impl TryFrom<&[u8]> for Link { link_health: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), desired_status: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), link_topologies: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - unicast_drained: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + link_flags: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), }; if out.account_type != AccountType::Link { @@ -410,6 +414,14 @@ impl Validate for Link { msg!("Invalid link endpoints: side_a_pk and side_z_pk must be different"); return Err(DoubleZeroError::InvalidDevicePubkey); } + // A link may belong to at most 8 topologies + if self.link_topologies.len() > 8 { + msg!( + "link_topologies exceeds maximum of 8 (got {})", + self.link_topologies.len() + ); + return Err(DoubleZeroError::InvalidArgument); + } Ok(()) } } @@ -432,6 +444,10 @@ impl Link { /// This method mutates the `status` field of the `Link` in-place. /// Where `_` means any value is valid for that field. /// + pub fn is_unicast_drained(&self) -> bool { + self.link_flags & LINK_FLAG_UNICAST_DRAINED != 0 + } + #[allow(unreachable_code)] pub fn check_status_transition(&mut self) { // waiting for health oracle to implement this logic @@ -556,7 +572,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let data = borsh::to_vec(&val).unwrap(); @@ -611,7 +627,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let err = val.validate(); assert!(err.is_err()); @@ -643,7 +659,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let err = val.validate(); assert!(err.is_err()); @@ -675,7 +691,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; // For Rejected status, tunnel_net is not validated and should succeed @@ -707,7 +723,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let err = val.validate(); assert!(err.is_err()); @@ -739,7 +755,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -779,7 +795,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -820,7 +836,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let err = val.validate(); @@ -853,7 +869,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -893,7 +909,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -933,8 +949,45 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; assert!(bad_link.validate().is_ok()); } + + #[test] + fn test_state_link_validate_error_too_many_topologies() { + let valid_link = Link { + account_type: AccountType::Link, + owner: Pubkey::new_unique(), + index: 123, + bump_seed: 1, + contributor_pk: Pubkey::new_unique(), + side_a_pk: Pubkey::new_unique(), + side_z_pk: Pubkey::new_unique(), + link_type: LinkLinkType::WAN, + bandwidth: 10_000_000_000, + mtu: 1566, + delay_ns: 1_000_000, + jitter_ns: 1_000_000, + tunnel_id: 1, + tunnel_net: "10.0.0.1/25".parse().unwrap(), + code: "test-123".to_string(), + status: LinkStatus::Activated, + side_a_iface_name: "eth0".to_string(), + side_z_iface_name: "eth1".to_string(), + delay_override_ns: 0, + link_health: LinkHealth::ReadyForService, + desired_status: LinkDesiredStatus::Activated, + link_topologies: (0..8).map(|_| Pubkey::new_unique()).collect(), + link_flags: 0, + }; + assert!(valid_link.validate().is_ok()); + + let too_many = Link { + link_topologies: (0..9).map(|_| Pubkey::new_unique()).collect(), + ..valid_link + }; + let err = too_many.validate(); + assert_eq!(err.unwrap_err(), DoubleZeroError::InvalidArgument); + } } diff --git a/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs b/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs index 4b5cd52b5f..376096ca18 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs @@ -104,6 +104,7 @@ async fn test_delete_cyoa_interface_with_invalid_sibling() { ip_net: "63.243.225.62/30".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], }; // Interface B: INVALID CYOA interface — CYOA set but ip_net is default (0.0.0.0/0). @@ -123,6 +124,7 @@ async fn test_delete_cyoa_interface_with_invalid_sibling() { ip_net: NetworkV4::default(), // <-- INVALID: CYOA without ip_net node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], }; let device = Device { @@ -337,6 +339,7 @@ async fn test_update_cyoa_interface_with_invalid_sibling() { ip_net: "63.243.225.62/30".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], }; // Interface B: INVALID CYOA interface — CYOA set but ip_net is default. @@ -355,6 +358,7 @@ async fn test_update_cyoa_interface_with_invalid_sibling() { ip_net: NetworkV4::default(), // <-- INVALID: CYOA without ip_net node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], }; let device = Device { @@ -410,7 +414,7 @@ async fn test_update_cyoa_interface_with_invalid_sibling() { program_id, DoubleZeroInstruction::UpdateDeviceInterface(DeviceInterfaceUpdateArgs { name: "ethernet1".to_string(), - mtu: Some(1500), + mtu: Some(9000), ..Default::default() }), vec![ @@ -434,10 +438,7 @@ async fn test_update_cyoa_interface_with_invalid_sibling() { .unwrap(); let updated_iface = device.find_interface("Ethernet1").unwrap().1; - assert_eq!( - updated_iface.mtu, 1500, - "MTU should remain 1500 for CYOA interface" - ); + assert_eq!(updated_iface.mtu, 9000, "MTU should be updated to 9000"); assert_eq!( updated_iface.ip_net, "63.243.225.62/30".parse().unwrap(), diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index 48fa2c9490..01d9b8f9ca 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -231,7 +231,7 @@ async fn test_wan_link() { bandwidth: 0, ip_net: None, cir: 0, - mtu: 9000, + mtu: 1500, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -362,7 +362,7 @@ async fn test_wan_link() { bandwidth: 0, ip_net: None, cir: 0, - mtu: 9000, + mtu: 1500, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -610,7 +610,7 @@ async fn test_wan_link() { contributor_pk: Some(contributor_pubkey), tunnel_type: Some(LinkLinkType::WAN), bandwidth: Some(20000000000), - mtu: Some(9000), + mtu: Some(8900), delay_ns: Some(1000000), jitter_ns: Some(100000), delay_override_ns: Some(0), @@ -639,7 +639,7 @@ async fn test_wan_link() { assert_eq!(tunnel_la.account_type, AccountType::Link); assert_eq!(tunnel_la.code, "la2".to_string()); assert_eq!(tunnel_la.bandwidth, 20000000000); - assert_eq!(tunnel_la.mtu, 9000); + assert_eq!(tunnel_la.mtu, 8900); assert_eq!(tunnel_la.delay_ns, 1000000); assert_eq!(tunnel_la.status, LinkStatus::Activated); assert_eq!(tunnel_la.desired_status, LinkDesiredStatus::Activated); @@ -823,7 +823,7 @@ async fn test_wan_link() { assert_eq!(tunnel_la.account_type, AccountType::Link); assert_eq!(tunnel_la.code, "la2".to_string()); assert_eq!(tunnel_la.bandwidth, 20000000000); - assert_eq!(tunnel_la.mtu, 9000); + assert_eq!(tunnel_la.mtu, 8900); assert_eq!(tunnel_la.delay_ns, 1000000); assert_eq!(tunnel_la.status, LinkStatus::Deleting); @@ -1149,7 +1149,7 @@ async fn test_wan_link_rejects_cyoa_interface() { bandwidth: 0, ip_net: None, cir: 0, - mtu: 9000, + mtu: 1500, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -1249,7 +1249,7 @@ async fn test_wan_link_rejects_cyoa_interface() { loopback_type: None, bandwidth: None, cir: None, - mtu: Some(9000), + mtu: None, routing_mode: None, vlan_id: None, user_tunnel_endpoint: None, @@ -1277,7 +1277,7 @@ async fn test_wan_link_rejects_cyoa_interface() { loopback_type: None, bandwidth: None, cir: None, - mtu: Some(1500), + mtu: None, routing_mode: None, vlan_id: None, user_tunnel_endpoint: None, @@ -1341,7 +1341,7 @@ async fn test_wan_link_rejects_cyoa_interface() { loopback_type: None, bandwidth: None, cir: None, - mtu: Some(9000), + mtu: None, routing_mode: None, vlan_id: None, user_tunnel_endpoint: None, @@ -1398,7 +1398,7 @@ async fn test_wan_link_rejects_cyoa_interface() { loopback_type: None, bandwidth: None, cir: None, - mtu: Some(1500), + mtu: None, routing_mode: None, vlan_id: None, user_tunnel_endpoint: None, @@ -1623,7 +1623,7 @@ async fn test_cannot_set_cyoa_on_linked_interface() { bandwidth: 0, ip_net: None, cir: 0, - mtu: 9000, + mtu: 1500, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -1714,7 +1714,7 @@ async fn test_cannot_set_cyoa_on_linked_interface() { bandwidth: 0, ip_net: None, cir: 0, - mtu: 9000, + mtu: 1500, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -2088,7 +2088,7 @@ async fn setup_link_env() -> ( bandwidth: 0, ip_net: None, cir: 0, - mtu: 9000, + mtu: 1500, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -2176,7 +2176,7 @@ async fn setup_link_env() -> ( bandwidth: 0, ip_net: None, cir: 0, - mtu: 9000, + mtu: 1500, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -2664,57 +2664,3 @@ async fn test_link_activation_fails_without_unicast_default() { error_string ); } - -#[tokio::test] -async fn test_link_create_invalid_mtu() { - let ( - mut banks_client, - program_id, - payer, - globalstate_pubkey, - contributor_pubkey, - device_a_pubkey, - device_z_pubkey, - _tunnel_pubkey, - ) = setup_link_env().await; - - let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); - - // Create link with MTU 1500 (should fail, must be 9000) - let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; - let (tunnel_pubkey, _) = get_link_pda(&program_id, globalstate_account.account_index + 1); - - let res = try_execute_transaction( - &mut banks_client, - recent_blockhash, - program_id, - DoubleZeroInstruction::CreateLink(LinkCreateArgs { - code: "invalid-mtu".to_string(), - link_type: LinkLinkType::WAN, - bandwidth: 20000000000, - mtu: 1500, - delay_ns: 1000000, - jitter_ns: 100000, - side_a_iface_name: "Ethernet0".to_string(), - side_z_iface_name: Some("Ethernet1".to_string()), - desired_status: Some(LinkDesiredStatus::Activated), - use_onchain_allocation: false, - }), - vec![ - AccountMeta::new(tunnel_pubkey, false), - AccountMeta::new(contributor_pubkey, false), - AccountMeta::new(device_a_pubkey, false), - AccountMeta::new(device_z_pubkey, false), - AccountMeta::new(globalstate_pubkey, false), - ], - &payer, - ) - .await; - - let error_string = format!("{:?}", res.unwrap_err()); - assert!( - error_string.contains("Custom(46)"), - "Expected InvalidMtu error (Custom(46)), got: {}", - error_string - ); -} diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index 4c6a69d13e..9173cf8574 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -1,4 +1,4 @@ -//! Tests for TopologyInfo, FlexAlgoNodeSegment, and InterfaceV3 (RFC-18 / Link Classification). +//! Tests for TopologyInfo and FlexAlgoNodeSegment (RFC-18 / Link Classification). use doublezero_serviceability::{ instructions::DoubleZeroInstruction, @@ -24,7 +24,7 @@ use doublezero_serviceability::{ delete::TopologyDeleteArgs, }, }, - resource::{IdOrIp, ResourceType}, + resource::ResourceType, state::{ accounttype::AccountType, device::{DeviceDesiredStatus, DeviceType}, @@ -105,17 +105,17 @@ async fn test_admin_group_bits_create_and_pre_mark() { "AdminGroupBits account should have non-empty data" ); - // Verify bit 1 (UNICAST-DRAINED) is pre-marked + // Bit 0 is implicitly reserved for UNICAST-DRAINED via IdRange(1, 127). + // No bits are pre-marked at resource creation time. let resource = get_resource_extension_data(&mut banks_client, resource_pubkey) .await .expect("AdminGroupBits resource extension should be deserializable"); let allocated = resource.iter_allocated(); - assert_eq!(allocated.len(), 1, "exactly one bit should be pre-marked"); assert_eq!( - allocated[0], - IdOrIp::Id(1), - "bit 1 (UNICAST-DRAINED) should be pre-marked" + allocated.len(), + 0, + "no bits should be pre-marked at creation" ); println!("[PASS] test_admin_group_bits_create_and_pre_mark"); @@ -160,8 +160,8 @@ fn test_flex_algo_node_segment_roundtrip() { // ============================================================================ #[tokio::test] -async fn test_topology_create_bit_0_first() { - println!("[TEST] test_topology_create_bit_0_first"); +async fn test_topology_create_bit_1_first() { + println!("[TEST] test_topology_create_bit_1_first"); let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; @@ -184,16 +184,18 @@ async fn test_topology_create_bit_0_first() { assert_eq!(topology.account_type, AccountType::Topology); assert_eq!(topology.name, "unicast-default"); - assert_eq!(topology.admin_group_bit, 0); - assert_eq!(topology.flex_algo_number, 128); + // Bit 0 is reserved for UNICAST-DRAINED (implicit via IdRange(1, 127)), + // so the first user topology gets bit 1. + assert_eq!(topology.admin_group_bit, 1); + assert_eq!(topology.flex_algo_number, 129); assert_eq!(topology.constraint, TopologyConstraint::IncludeAny); - println!("[PASS] test_topology_create_bit_0_first"); + println!("[PASS] test_topology_create_bit_1_first"); } #[tokio::test] -async fn test_topology_create_second_skips_bit_1() { - println!("[TEST] test_topology_create_second_skips_bit_1"); +async fn test_topology_create_consecutive_bits() { + println!("[TEST] test_topology_create_consecutive_bits"); let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; @@ -201,7 +203,7 @@ async fn test_topology_create_second_skips_bit_1() { let (admin_group_bits_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); - // First topology gets bit 0 + // First topology gets bit 1 (bit 0 is implicitly reserved for UNICAST-DRAINED) create_topology( &mut banks_client, program_id, @@ -213,7 +215,7 @@ async fn test_topology_create_second_skips_bit_1() { ) .await; - // Second topology must skip bit 1 (pre-marked UNICAST-DRAINED) and get bit 2 + // Second topology gets the next consecutive bit (2) let topology_pda = create_topology( &mut banks_client, program_id, @@ -228,13 +230,10 @@ async fn test_topology_create_second_skips_bit_1() { let topology = get_topology(&mut banks_client, topology_pda).await; assert_eq!(topology.name, "shelby"); - assert_eq!( - topology.admin_group_bit, 2, - "bit 1 should be skipped (UNICAST-DRAINED)" - ); + assert_eq!(topology.admin_group_bit, 2); assert_eq!(topology.flex_algo_number, 130); - println!("[PASS] test_topology_create_second_skips_bit_1"); + println!("[PASS] test_topology_create_consecutive_bits"); } #[tokio::test] @@ -1221,7 +1220,7 @@ async fn test_topology_delete_bit_not_reused() { let (admin_group_bits_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); - // Create "topology-a" — gets bit 0 + // Create "topology-a" — gets bit 1 (first available since bit 0 is reserved for UNICAST-DRAINED) create_topology( &mut banks_client, program_id, @@ -1245,7 +1244,7 @@ async fn test_topology_delete_bit_not_reused() { .await .expect("Delete should succeed"); - // Create "topology-b" — must NOT get bit 0 (permanently marked) or bit 1 (UNICAST-DRAINED) + // Create "topology-b" — must NOT get bit 1 (permanently marked even after delete), // so it should get bit 2 let topology_b_pda = create_topology( &mut banks_client, @@ -1261,7 +1260,7 @@ async fn test_topology_delete_bit_not_reused() { let topology_b = get_topology(&mut banks_client, topology_b_pda).await; assert_eq!( topology_b.admin_group_bit, 2, - "topology-b should get bit 2 (bit 0 permanently marked even after delete, bit 1 is UNICAST-DRAINED)" + "topology-b should get bit 2 (bit 1 permanently marked even after delete)" ); println!("[PASS] test_topology_delete_bit_not_reused"); @@ -1890,6 +1889,265 @@ async fn test_topology_backfill_nonexistent_topology_rejected() { println!("[PASS] test_topology_backfill_nonexistent_topology_rejected"); } +#[tokio::test] +async fn test_topology_backfill_avoids_collision_with_existing_node_segment_idx() { + // Regression test: BackfillTopology must not re-use the base node_segment_idx + // when the on-chain SegmentRoutingIds resource was never updated by the activator + // (use_onchain_allocation=false path). Before the fix, backfill would allocate + // ID 1 for the flex-algo segment even though ID 1 was already used as the base + // node_segment_idx on the loopback. + println!("[TEST] test_topology_backfill_avoids_collision_with_existing_node_segment_idx"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); + + // Step 1: Create Location + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (location_pubkey, _) = get_location_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateLocation(LocationCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + country: "us".to_string(), + lat: 1.234, + lng: 4.567, + loc_id: 0, + }), + vec![ + AccountMeta::new(location_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 2: Create Exchange + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (exchange_pubkey, _) = get_exchange_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateExchange(ExchangeCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + lat: 1.234, + lng: 4.567, + reserved: 0, + }), + vec![ + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 3: Create Contributor + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (contributor_pubkey, _) = + get_contributor_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateContributor(ContributorCreateArgs { + code: "cont".to_string(), + }), + vec![ + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(payer.pubkey(), false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 4: Create Device + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (device_pubkey, _) = get_device_pda(&program_id, globalstate_account.account_index + 1); + let (tunnel_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::TunnelIds(device_pubkey, 0)); + let (dz_prefix_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::DzPrefixBlock(device_pubkey, 0)); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDevice(DeviceCreateArgs { + code: "dz1".to_string(), + device_type: DeviceType::Hybrid, + public_ip: [8, 8, 8, 8].into(), + dz_prefixes: "110.1.0.0/23".parse().unwrap(), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "mgmt".to_string(), + desired_status: Some(DeviceDesiredStatus::Activated), + resource_count: 0, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 5: Activate Device + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateDevice(DeviceActivateArgs { resource_count: 2 }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(tunnel_ids_pda, false), + AccountMeta::new(dz_prefix_pda, false), + ], + &payer, + ) + .await; + + // Step 6: Create a Vpnv4 loopback interface (without onchain allocation) + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDeviceInterface(DeviceInterfaceCreateArgs { + name: "Loopback255".to_string(), + interface_dia: InterfaceDIA::None, + loopback_type: LoopbackType::Vpnv4, + interface_cyoa: InterfaceCYOA::None, + bandwidth: 0, + cir: 0, + ip_net: None, + mtu: 1500, + routing_mode: RoutingMode::Static, + vlan_id: 0, + user_tunnel_endpoint: false, + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 7: Activate the loopback with explicit node_segment_idx=1, WITHOUT providing + // the SegmentRoutingIds account. This simulates the activator's use_onchain_allocation=false + // path: the base SR ID is set to 1 but the on-chain resource is never updated, so the + // resource still believes ID 1 is free. + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateDeviceInterface(DeviceInterfaceActivateArgs { + name: "Loopback255".to_string(), + ip_net: "172.16.0.1/32".parse().unwrap(), + node_segment_idx: 1, + }), + // Only device + globalstate — no link_ips or segment_routing_ids accounts. + // This causes the processor to take the else branch and store node_segment_idx + // directly without updating the on-chain resource (accounts.len() == 4). + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Verify base state: loopback has node_segment_idx=1, no flex-algo segments yet. + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found"); + let iface = device.interfaces[0].into_current_version(); + assert_eq!( + iface.node_segment_idx, 1, + "Base node_segment_idx should be 1" + ); + assert_eq!( + iface.flex_algo_node_segments.len(), + 0, + "No flex-algo segments before backfill" + ); + + // Step 8: Create topology + let topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Step 9: Call BackfillTopology. With the fix, the pre-mark pass marks ID 1 as used + // before allocating, so the flex-algo segment receives ID 2 (not 1). + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + let base_accounts = vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]; + let extra_accounts = vec![AccountMeta::new(device_pubkey, false)]; + let mut tx = create_transaction_with_extra_accounts( + program_id, + &DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + name: "unicast-default".to_string(), + }), + &base_accounts, + &payer, + &extra_accounts, + ); + tx.try_sign(&[&payer], recent_blockhash).unwrap(); + banks_client.process_transaction(tx).await.unwrap(); + + // Verify: flex-algo segment has node_segment_idx=2, NOT 1 (which is the base idx). + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found after backfill"); + let iface = device.interfaces[0].into_current_version(); + assert_eq!( + iface.node_segment_idx, 1, + "Base node_segment_idx must remain 1" + ); + assert_eq!( + iface.flex_algo_node_segments.len(), + 1, + "Expected one flex_algo_node_segment after backfill" + ); + assert_eq!( + iface.flex_algo_node_segments[0].topology, topology_pda, + "Segment should point to the backfilled topology" + ); + assert_eq!( + iface.flex_algo_node_segments[0].node_segment_idx, 2, + "flex-algo node_segment_idx must be 2 (fresh allocation), not 1 (base)" + ); + + println!("[PASS] test_topology_backfill_avoids_collision_with_existing_node_segment_idx"); +} + // ============================================================================ // unicast_drained tests // ============================================================================ @@ -1915,7 +2173,7 @@ async fn test_link_unicast_drained_contributor_can_set_own_link() { // Verify unicast_drained starts as false let link = get_link(&mut banks_client, link_pubkey).await; - assert!(!link.unicast_drained); + assert!(!link.is_unicast_drained()); // Contributor A (payer) sets unicast_drained = true let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); @@ -1938,7 +2196,7 @@ async fn test_link_unicast_drained_contributor_can_set_own_link() { // Read back: unicast_drained must be true let link = get_link(&mut banks_client, link_pubkey).await; - assert!(link.unicast_drained); + assert!(link.is_unicast_drained()); println!("[PASS] test_link_unicast_drained_contributor_can_set_own_link"); } @@ -2058,7 +2316,7 @@ async fn test_link_unicast_drained_foundation_can_set_any_link() { .await; let link = get_link(&mut banks_client, link_pubkey).await; - assert!(link.unicast_drained); + assert!(link.is_unicast_drained()); println!("[PASS] test_link_unicast_drained_foundation_can_set_any_link"); } @@ -2113,7 +2371,7 @@ async fn test_link_unicast_drained_orthogonal_to_status_and_topologies() { let link_before = get_link(&mut banks_client, link_pubkey).await; assert!(link_before.link_topologies.contains(&topology_pda)); - assert!(!link_before.unicast_drained); + assert!(!link_before.is_unicast_drained()); // Set unicast_drained = true let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); @@ -2135,7 +2393,10 @@ async fn test_link_unicast_drained_orthogonal_to_status_and_topologies() { .await; let link_after = get_link(&mut banks_client, link_pubkey).await; - assert!(link_after.unicast_drained, "unicast_drained should be true"); + assert!( + link_after.is_unicast_drained(), + "unicast_drained should be true" + ); assert_eq!( link_after.status, link_before.status, "status should be unchanged" diff --git a/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs b/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs index bb1fa261a9..0bb16d2383 100644 --- a/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs +++ b/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs @@ -703,7 +703,7 @@ async fn test_initialize_device_latency_samples_fail_link_wrong_owner() { jitter_ns: 10000, delay_override_ns: 0, link_type: LinkLinkType::WAN, - mtu: 9000, + mtu: 0, tunnel_id: 0, tunnel_net: NetworkV4::default(), side_a_iface_name: "Ethernet0".to_string(), @@ -711,7 +711,7 @@ async fn test_initialize_device_latency_samples_fail_link_wrong_owner() { link_health: LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let mut data = Vec::new(); @@ -854,7 +854,7 @@ async fn test_initialize_device_latency_samples_fail_origin_device_not_activated code: "LINK1".to_string(), link_type: LinkLinkType::WAN, bandwidth: 10_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 1000000, jitter_ns: 100000, side_a_iface_name: "Ethernet0".to_string(), @@ -992,7 +992,7 @@ async fn test_initialize_device_latency_samples_fail_target_device_not_activated code: "LINK1".to_string(), link_type: LinkLinkType::WAN, bandwidth: 10_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 1000000, jitter_ns: 100000, side_a_iface_name: "Ethernet0".to_string(), @@ -1128,7 +1128,7 @@ async fn test_initialize_device_latency_samples_success_provisioning_link() { code: "LINK1".to_string(), link_type: LinkLinkType::WAN, bandwidth: 10_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 1000000, jitter_ns: 100000, side_a_iface_name: "Ethernet0".to_string(), @@ -1392,7 +1392,7 @@ async fn test_initialize_device_latency_samples_fail_link_wrong_devices() { code: "LINK1".to_string(), link_type: LinkLinkType::WAN, bandwidth: 10_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 1000000, jitter_ns: 100000, side_a_iface_name: "Ethernet0".to_string(), @@ -1528,7 +1528,7 @@ async fn test_initialize_device_latency_samples_succeeds_with_reversed_link_side code: "LINK1".into(), link_type: LinkLinkType::WAN, bandwidth: 10_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 1000000, jitter_ns: 100000, side_a_iface_name: "Ethernet1".to_string(), @@ -1861,7 +1861,7 @@ async fn test_initialize_device_latency_samples_fail_agent_not_owner_of_origin_d code: "LNK".to_string(), link_type: LinkLinkType::WAN, bandwidth: 10_000_000_000, - mtu: 9000, + mtu: 4500, delay_ns: 1000000, jitter_ns: 100000, side_a_iface_name: "Ethernet0".to_string(), diff --git a/smartcontract/sdk/go/serviceability/deserialize.go b/smartcontract/sdk/go/serviceability/deserialize.go index 809d172538..b444b8b257 100644 --- a/smartcontract/sdk/go/serviceability/deserialize.go +++ b/smartcontract/sdk/go/serviceability/deserialize.go @@ -73,6 +73,7 @@ func DeserializeTenant(reader *ByteReader, tenant *Tenant) { tenant.BillingDiscriminant = reader.ReadU8() tenant.BillingRate = reader.ReadU64() tenant.BillingLastDeductionDzEpoch = reader.ReadU64() + tenant.IncludeTopologies = reader.ReadPubkeySlice() // Note: tenant.PubKey is set separately in client.go after deserialization } @@ -121,25 +122,16 @@ func DeserializeInterfaceV2(reader *ByteReader, iface *Interface) { iface.IpNet = reader.ReadNetworkV4() iface.NodeSegmentIdx = reader.ReadU16() iface.UserTunnelEndpoint = (reader.ReadU8() != 0) + // flex_algo_node_segments was merged into V2 from the old V3. + // Old V2 accounts (written before this field existed) will have no trailing + // bytes — ReadFlexAlgoNodeSegmentSlice returns nil/empty in that case. + iface.FlexAlgoNodeSegments = reader.ReadFlexAlgoNodeSegmentSlice() } +// DeserializeInterfaceV3 handles legacy on-chain accounts written with +// discriminant 2 (the old V3). Their layout is identical to the current V2. func DeserializeInterfaceV3(reader *ByteReader, iface *Interface) { - iface.Status = InterfaceStatus(reader.ReadU8()) - iface.Name = reader.ReadString() - iface.InterfaceType = InterfaceType(reader.ReadU8()) - iface.InterfaceCYOA = InterfaceCYOA(reader.ReadU8()) - iface.InterfaceDIA = InterfaceDIA(reader.ReadU8()) - loopbackTypeByte := reader.ReadU8() - iface.LoopbackType = LoopbackType(loopbackTypeByte) - iface.Bandwidth = reader.ReadU64() - iface.Cir = reader.ReadU64() - iface.Mtu = reader.ReadU16() - iface.RoutingMode = RoutingMode(reader.ReadU8()) - iface.VlanId = reader.ReadU16() - iface.IpNet = reader.ReadNetworkV4() - iface.NodeSegmentIdx = reader.ReadU16() - iface.UserTunnelEndpoint = (reader.ReadU8() != 0) - iface.FlexAlgoNodeSegments = reader.ReadFlexAlgoNodeSegmentSlice() + DeserializeInterfaceV2(reader, iface) } func DeserializeDevice(reader *ByteReader, dev *Device) { @@ -203,7 +195,10 @@ func DeserializeLink(reader *ByteReader, link *Link) { link.SideAIfaceName = reader.ReadString() link.SideZIfaceName = reader.ReadString() link.DelayOverrideNs = reader.ReadU64() - link.PubKey = reader.ReadPubkey() + link.LinkHealth = LinkHealth(reader.ReadU8()) + link.LinkDesiredStatus = LinkDesiredStatus(reader.ReadU8()) + link.LinkTopologies = reader.ReadPubkeySlice() + link.LinkFlags = reader.ReadU8() } func DeserializeUser(reader *ByteReader, user *User) { @@ -322,3 +317,14 @@ func DeserializeResourceExtension(reader *ByteReader, ext *ResourceExtension) { ext.Storage = reader.ReadBytes(remaining) } } + +func DeserializeTopologyInfo(reader *ByteReader, t *TopologyInfo) { + t.AccountType = AccountType(reader.ReadU8()) + t.Owner = reader.ReadPubkey() + t.BumpSeed = reader.ReadU8() + t.Name = reader.ReadString() + t.AdminGroupBit = reader.ReadU8() + t.FlexAlgoNumber = reader.ReadU8() + t.Constraint = TopologyConstraint(reader.ReadU8()) + // Note: t.PubKey is set from the account address in client.go after deserialization +} diff --git a/smartcontract/sdk/go/serviceability/state.go b/smartcontract/sdk/go/serviceability/state.go index 824899c78c..2201269f36 100644 --- a/smartcontract/sdk/go/serviceability/state.go +++ b/smartcontract/sdk/go/serviceability/state.go @@ -26,6 +26,7 @@ const ( TenantType // 13 // 14 is reserved PermissionType AccountType = 15 + TopologyType AccountType = 16 ) type LocationStatus uint8 @@ -352,7 +353,7 @@ func (l RoutingMode) MarshalJSON() ([]byte, error) { } type FlexAlgoNodeSegment struct { - Topology [32]uint8 + Topology [32]byte NodeSegmentIdx uint16 } @@ -370,9 +371,9 @@ type Interface struct { RoutingMode RoutingMode VlanId uint16 IpNet [5]uint8 - NodeSegmentIdx uint16 - UserTunnelEndpoint bool - FlexAlgoNodeSegments []FlexAlgoNodeSegment + NodeSegmentIdx uint16 + UserTunnelEndpoint bool + FlexAlgoNodeSegments []FlexAlgoNodeSegment `json:",omitempty"` } func (i Interface) MarshalJSON() ([]byte, error) { @@ -617,6 +618,8 @@ type Link struct { LinkHealth LinkHealth `influx:"field,link_health"` LinkDesiredStatus LinkDesiredStatus `influx:"tag,link_desired_status"` PubKey [32]byte `influx:"tag,pubkey,pubkey"` + LinkTopologies [][32]byte + LinkFlags uint8 } func (l Link) MarshalJSON() ([]byte, error) { @@ -737,6 +740,7 @@ type Tenant struct { BillingRate uint64 `influx:"field,billing_rate"` BillingLastDeductionDzEpoch uint64 `influx:"field,billing_last_deduction_dz_epoch"` PubKey [32]byte `influx:"tag,pubkey,pubkey"` + IncludeTopologies [][32]byte } func (t Tenant) MarshalJSON() ([]byte, error) { @@ -1174,3 +1178,32 @@ func (r ResourceExtension) MarshalJSON() ([]byte, error) { return json.Marshal(jsonExt) } + +type TopologyConstraint uint8 + +const ( + TopologyConstraintIncludeAny TopologyConstraint = 0 + TopologyConstraintExclude TopologyConstraint = 1 +) + +func (c TopologyConstraint) String() string { + switch c { + case TopologyConstraintIncludeAny: + return "include-any" + case TopologyConstraintExclude: + return "exclude" + default: + return "unknown" + } +} + +type TopologyInfo struct { + AccountType AccountType + Owner [32]byte + BumpSeed uint8 + Name string + AdminGroupBit uint8 + FlexAlgoNumber uint8 + Constraint TopologyConstraint + PubKey [32]byte +} diff --git a/smartcontract/sdk/go/serviceability/state_test.go b/smartcontract/sdk/go/serviceability/state_test.go index 51db7f2736..e247e2bf27 100644 --- a/smartcontract/sdk/go/serviceability/state_test.go +++ b/smartcontract/sdk/go/serviceability/state_test.go @@ -71,6 +71,8 @@ func TestCustomJSONMarshal(t *testing.T) { "SideAIfaceName": "Switch1/1/1", "SideZIfaceName": "Switch1/1/1", "DelayOverrideNs": 10, + "LinkTopologies": null, + "LinkFlags": 0, "PubKey": "` + dummyPubKeyB58 + `" }`, expectErr: false, @@ -122,6 +124,8 @@ func TestCustomJSONMarshal(t *testing.T) { "SideAIfaceName": "Edge1/0/0", "SideZIfaceName": "Edge2/0/0", "DelayOverrideNs": 0, + "LinkTopologies": null, + "LinkFlags": 0, "PubKey": "` + dummyPubKeyB58 + `" }`, expectErr: false, @@ -153,6 +157,8 @@ func TestCustomJSONMarshal(t *testing.T) { "SideAIfaceName": "", "SideZIfaceName": "", "DelayOverrideNs": 0, + "LinkTopologies": null, + "LinkFlags": 0, "PubKey": "11111111111111111111111111111111" }`, expectErr: false, @@ -304,7 +310,7 @@ func TestCustomJSONMarshal(t *testing.T) { "MgmtVrf": "mgmt-vrf", "Interfaces": [ { - "Version": 1, + "Version": 2, "Status": "activated", "Name": "Switch1/1/1", "InterfaceType": "physical", @@ -410,6 +416,7 @@ func TestCustomJSONMarshal(t *testing.T) { "BillingDiscriminant": 0, "BillingRate": 0, "BillingLastDeductionDzEpoch": 0, + "IncludeTopologies": null, "PubKey": "` + dummyPubKeyB58 + `" }`, expectErr: false, diff --git a/smartcontract/sdk/rs/src/commands/contributor/create.rs b/smartcontract/sdk/rs/src/commands/contributor/create.rs index 229f0fddcf..257d0e2afb 100644 --- a/smartcontract/sdk/rs/src/commands/contributor/create.rs +++ b/smartcontract/sdk/rs/src/commands/contributor/create.rs @@ -30,6 +30,7 @@ impl CreateContributorCommand { AccountMeta::new(pda_pubkey, false), AccountMeta::new(self.owner, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(client.get_payer(), true), // TEMP: workaround for --owner me payer bug ], ) .map(|sig| (sig, pda_pubkey)) diff --git a/smartcontract/sdk/rs/src/commands/link/accept.rs b/smartcontract/sdk/rs/src/commands/link/accept.rs index 565be69caf..387bde3d34 100644 --- a/smartcontract/sdk/rs/src/commands/link/accept.rs +++ b/smartcontract/sdk/rs/src/commands/link/accept.rs @@ -136,7 +136,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let device_z = doublezero_serviceability::state::device::Device { @@ -240,7 +240,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let device_z = doublezero_serviceability::state::device::Device { diff --git a/smartcontract/sdk/rs/src/commands/link/activate.rs b/smartcontract/sdk/rs/src/commands/link/activate.rs index 48ad41e7fa..aa2a4c4b73 100644 --- a/smartcontract/sdk/rs/src/commands/link/activate.rs +++ b/smartcontract/sdk/rs/src/commands/link/activate.rs @@ -138,7 +138,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; // Mock Link fetch @@ -213,7 +213,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; // Compute ResourceExtension PDAs diff --git a/smartcontract/sdk/rs/src/commands/link/closeaccount.rs b/smartcontract/sdk/rs/src/commands/link/closeaccount.rs index 4a90b74dcc..a6424c700b 100644 --- a/smartcontract/sdk/rs/src/commands/link/closeaccount.rs +++ b/smartcontract/sdk/rs/src/commands/link/closeaccount.rs @@ -117,7 +117,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; // Mock Link fetch @@ -190,7 +190,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; // Compute ResourceExtension PDAs diff --git a/smartcontract/sdk/rs/src/commands/link/delete.rs b/smartcontract/sdk/rs/src/commands/link/delete.rs index 98c0ef2cbb..979ba522b6 100644 --- a/smartcontract/sdk/rs/src/commands/link/delete.rs +++ b/smartcontract/sdk/rs/src/commands/link/delete.rs @@ -108,7 +108,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, } } diff --git a/smartcontract/sdk/rs/src/commands/topology/create.rs b/smartcontract/sdk/rs/src/commands/topology/create.rs index c8efc3668e..f63344072b 100644 --- a/smartcontract/sdk/rs/src/commands/topology/create.rs +++ b/smartcontract/sdk/rs/src/commands/topology/create.rs @@ -24,6 +24,15 @@ impl CreateTopologyCommand { let (admin_group_bits_pda, _, _) = get_resource_extension_pda(&client.get_program_id(), ResourceType::AdminGroupBits); + // Pre-flight: verify admin-group-bits resource account exists + client.get_account(admin_group_bits_pda).map_err(|_| { + eyre::eyre!( + "admin-group-bits resource account not found ({}). \ + Run 'doublezero resource create --resource-type admin-group-bits' first.", + admin_group_bits_pda + ) + })?; + client .execute_transaction( DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { @@ -54,7 +63,7 @@ mod tests { state::topology::TopologyConstraint, }; use mockall::predicate; - use solana_sdk::{instruction::AccountMeta, signature::Signature}; + use solana_sdk::{account::Account, instruction::AccountMeta, signature::Signature}; #[test] fn test_commands_topology_create_command() { @@ -65,6 +74,11 @@ mod tests { let (admin_group_bits_pda, _, _) = get_resource_extension_pda(&client.get_program_id(), ResourceType::AdminGroupBits); + client + .expect_get_account() + .with(predicate::eq(admin_group_bits_pda)) + .returning(|_| Ok(Account::default())); + client .expect_execute_transaction() .with( diff --git a/smartcontract/test/start-test.sh b/smartcontract/test/start-test.sh index 12308ff925..1645adb200 100644 --- a/smartcontract/test/start-test.sh +++ b/smartcontract/test/start-test.sh @@ -12,22 +12,18 @@ mkdir -p ./logs ./target export OPENSSL_NO_VENDOR=1 -if [ "${CARGO_TARGET_DIR}" == "" ]; then - CARGO_TARGET_DIR="../../target" -fi - # Build the program echo "Build the program" -cargo build-sbf --manifest-path ../programs/doublezero-serviceability/Cargo.toml -- -Znext-lockfile-bump --target-dir ${CARGO_TARGET_DIR} -cp ${CARGO_TARGET_DIR}/deploy/doublezero_serviceability.so ./target/doublezero_serviceability.so +cargo build-sbf --manifest-path ../programs/doublezero-serviceability/Cargo.toml -- -Znext-lockfile-bump --target-dir ../../target/ +cp ../../target/deploy/doublezero_serviceability.so ./target/doublezero_serviceability.so #Build the activator echo "Build the activator" -cargo build --manifest-path ../../activator/Cargo.toml --target-dir ${CARGO_TARGET_DIR} ; cp ${CARGO_TARGET_DIR}/debug/doublezero-activator ./target/ +cargo build --manifest-path ../../activator/Cargo.toml --target-dir ../../target/ ; cp ../../target/debug/doublezero-activator ./target/ #Build the activator echo "Build the client" -cargo build --manifest-path ../../client/doublezero/Cargo.toml --target-dir ${CARGO_TARGET_DIR} ; cp ${CARGO_TARGET_DIR}/debug/doublezero ./target/ +cargo build --manifest-path ../../client/doublezero/Cargo.toml --target-dir ../../target/ ; cp ../../target/debug/doublezero ./target/ # Configure to connect to localnet solana config set --url http://127.0.0.1:8899 @@ -129,24 +125,24 @@ echo "Creating devices" ### Initialize device interfaces echo "Creating device interfaces" -./target/doublezero device interface create la2-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create la2-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create la2-dz01 "Switch1/1/3" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create ny5-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create ny5-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create ld4-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create ld4-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create ld4-dz01 "Switch1/1/3" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create frk-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create frk-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create sg1-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create sg1-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create ty2-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create ty2-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create pit-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create pit-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create ams-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create ams-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create la2-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create la2-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create la2-dz01 "Switch1/1/3" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create ny5-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create ny5-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create ld4-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create ld4-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create ld4-dz01 "Switch1/1/3" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create frk-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create frk-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create sg1-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create sg1-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create ty2-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create ty2-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create pit-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create pit-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create ams-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create ams-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w ### Initialize links echo "Creating internal links" @@ -168,7 +164,7 @@ echo "Creating devices" ### Initialize device interfaces echo "Creating device interfaces" -./target/doublezero device interface create la2-dz02 "Switch1/1/1" --bandwidth "1G" --cir "1G" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create la2-dz02 "Switch1/1/1" --bandwidth "1G" --cir "1G" --mtu 1500 --routing-mode static -w ### Initialize links echo "Creating external links" From 3aeb0904bce63bbc39b5a3c08dcf5a156fa83f37 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 6 Apr 2026 14:33:09 -0500 Subject: [PATCH 40/49] smartcontract: add topology account validation and cap enforcement to update_link; add topology tests --- .../src/processors/link/update.rs | 24 + .../tests/link_wan_test.rs | 620 ++++++++++++++++++ 2 files changed, 644 insertions(+) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs index e93d0f0749..6d0d0e3fb0 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs @@ -13,6 +13,7 @@ use crate::{ feature_flags::{is_feature_enabled, FeatureFlag}, globalstate::GlobalState, link::*, + topology::TopologyInfo, }, }; use borsh::BorshSerialize; @@ -121,6 +122,11 @@ pub fn process_update_link( if value.use_onchain_allocation { expected_without_side_z += 2; // device_tunnel_block, link_ids } + // Topology accounts are passed as trailing accounts after system_program. + // Include them in the expected count so side_z detection is not confused. + if let Some(ref link_topologies) = value.link_topologies { + expected_without_side_z += link_topologies.len(); + } let side_z_account: Option<&AccountInfo> = if accounts.len() > expected_without_side_z { Some(next_account_info(accounts_iter)?) } else { @@ -146,6 +152,7 @@ pub fn process_update_link( let payer_account = next_account_info(accounts_iter)?; let system_program = next_account_info(accounts_iter)?; + let topology_accounts: Vec<&AccountInfo> = accounts_iter.collect(); #[cfg(test)] msg!("process_update_link({:?})", value); @@ -376,6 +383,23 @@ pub fn process_update_link( msg!("link_topologies update requires foundation allowlist"); return Err(DoubleZeroError::NotAllowed.into()); } + if link_topologies.len() > 8 { + msg!("link_topologies exceeds maximum of 8 entries"); + return Err(DoubleZeroError::InvalidArgument.into()); + } + if link_topologies.len() != topology_accounts.len() { + msg!("link_topologies count does not match provided topology accounts"); + return Err(DoubleZeroError::InvalidArgument.into()); + } + for (pk, acc) in link_topologies.iter().zip(topology_accounts.iter()) { + if acc.key != pk || acc.owner != program_id || acc.data_is_empty() { + return Err(DoubleZeroError::InvalidArgument.into()); + } + TopologyInfo::try_from(*acc) + .map_err(|_| DoubleZeroError::InvalidAccountType)? + .validate() + .map_err(ProgramError::from)?; + } link.link_topologies = link_topologies.clone(); } diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index 01d9b8f9ca..66abee54c4 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -2664,3 +2664,623 @@ async fn test_link_activation_fails_without_unicast_default() { error_string ); } +} + +#[tokio::test] +async fn test_link_topology_cap_at_8_rejected() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + // Attempt to set 9 topology pubkeys — exceeds cap of 8 + let nine_pubkeys: Vec = (0..9).map(|_| Pubkey::new_unique()).collect(); + let result = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + link_topologies: Some(nine_pubkeys), + ..Default::default() + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let error_string = format!("{:?}", result.unwrap_err()); + assert!( + error_string.contains("Custom(65)"), + "Expected InvalidArgument error (Custom(65)), got: {}", + error_string + ); +} + +#[tokio::test] +async fn test_link_topology_invalid_account_rejected() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + contributor_pubkey, + _device_a_pubkey, + _device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(_device_a_pubkey, false), + AccountMeta::new(_device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + // Pass a bogus pubkey that has no onchain data — data_is_empty() → InvalidArgument + let bogus_pubkey = Pubkey::new_unique(); + let result = try_execute_transaction_with_extra_accounts( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + link_topologies: Some(vec![bogus_pubkey]), + ..Default::default() + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + &[AccountMeta::new_readonly(bogus_pubkey, false)], + ) + .await; + + let error_string = format!("{:?}", result.unwrap_err()); + assert!( + error_string.contains("Custom(65)"), + "Expected InvalidArgument error (Custom(65)), got: {}", + error_string + ); +} + +#[tokio::test] +async fn test_link_topology_valid_accepted() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + // Create a second topology to assign to the link + let (topo_a_pda, _) = get_topology_pda(&program_id, "topo-a"); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "topo-a".to_string(), + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(topo_a_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + // Assign the topology to the link — should succeed + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + try_execute_transaction_with_extra_accounts( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + link_topologies: Some(vec![topo_a_pda]), + ..Default::default() + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + &[AccountMeta::new_readonly(topo_a_pda, false)], + ) + .await + .expect("Setting valid topology on link should succeed"); +} + +#[tokio::test] +async fn test_link_create_invalid_mtu() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + _tunnel_pubkey, + ) = setup_link_env().await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + // Create link with MTU 1500 (should fail, must be 9000) + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (tunnel_pubkey, _) = get_link_pda(&program_id, globalstate_account.account_index + 1); + + let res = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateLink(LinkCreateArgs { + code: "invalid-mtu".to_string(), + link_type: LinkLinkType::WAN, + bandwidth: 20000000000, + mtu: 1500, + delay_ns: 1000000, + jitter_ns: 100000, + side_a_iface_name: "Ethernet0".to_string(), + side_z_iface_name: Some("Ethernet1".to_string()), + desired_status: Some(LinkDesiredStatus::Activated), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let error_string = format!("{:?}", res.unwrap_err()); + assert!( + error_string.contains("Custom(46)"), + "Expected InvalidMtu error (Custom(46)), got: {}", + error_string + ); +} + +// ─── link_topologies update tests ──────────────────────────────────────────── + +/// Foundation key can reassign link_topologies to a different topology after +/// activation, overriding the auto-tag set by ActivateLink. +#[tokio::test] +async fn test_link_topology_reassigned_by_foundation() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + _contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let recent_blockhash = banks_client + .get_latest_blockhash() + .await + .expect("Failed to get blockhash"); + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + // Create unicast-default topology (required for activation) + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalstate_pubkey, + &payer, + ) + .await; + + // Create a second topology: high-bandwidth + let (high_bandwidth_pda, _) = get_topology_pda(&program_id, "high-bandwidth"); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "high-bandwidth".to_string(), + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(high_bandwidth_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Activate — auto-tags with unicast-default + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + let link = get_account_data(&mut banks_client, tunnel_pubkey) + .await + .unwrap() + .get_tunnel() + .unwrap(); + assert_eq!(link.link_topologies, vec![unicast_default_pda]); + + // Foundation reassigns link_topologies to high-bandwidth + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction_with_extra_accounts( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + code: None, + contributor_pk: None, + tunnel_type: None, + bandwidth: None, + mtu: None, + delay_ns: None, + jitter_ns: None, + delay_override_ns: None, + status: None, + desired_status: None, + tunnel_id: None, + tunnel_net: None, + use_onchain_allocation: false, + link_topologies: Some(vec![high_bandwidth_pda]), + unicast_drained: None, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(_contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + &[AccountMeta::new_readonly(high_bandwidth_pda, false)], + ) + .await; + + let link = get_account_data(&mut banks_client, tunnel_pubkey) + .await + .unwrap() + .get_tunnel() + .unwrap(); + assert_eq!( + link.link_topologies, + vec![high_bandwidth_pda], + "link_topologies should be updated to high-bandwidth PDA" + ); +} + +/// Foundation key can clear link_topologies to an empty vector, removing the +/// link from all constrained topologies (multicast-only link case). +#[tokio::test] +async fn test_link_topology_cleared_by_foundation() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let recent_blockhash = banks_client + .get_latest_blockhash() + .await + .expect("Failed to get blockhash"); + + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalstate_pubkey, + &payer, + ) + .await; + + // Activate — auto-tags with unicast-default + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + let link = get_account_data(&mut banks_client, tunnel_pubkey) + .await + .unwrap() + .get_tunnel() + .unwrap(); + assert_eq!(link.link_topologies, vec![unicast_default_pda]); + + // Foundation clears link_topologies — link becomes multicast-only + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + code: None, + contributor_pk: None, + tunnel_type: None, + bandwidth: None, + mtu: None, + delay_ns: None, + jitter_ns: None, + delay_override_ns: None, + status: None, + desired_status: None, + tunnel_id: None, + tunnel_net: None, + use_onchain_allocation: false, + link_topologies: Some(vec![]), + unicast_drained: None, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let link = get_account_data(&mut banks_client, tunnel_pubkey) + .await + .unwrap() + .get_tunnel() + .unwrap(); + assert_eq!( + link.link_topologies, + vec![], + "link_topologies should be empty after clearing" + ); +} + +/// A non-foundation payer cannot set link_topologies — the instruction must +/// be rejected with NotAllowed (Custom(8)). +#[tokio::test] +async fn test_link_topology_update_rejected_for_non_foundation() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let recent_blockhash = banks_client + .get_latest_blockhash() + .await + .expect("Failed to get blockhash"); + + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalstate_pubkey, + &payer, + ) + .await; + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + // Create a non-foundation keypair and fund it + let non_foundation = Keypair::new(); + transfer( + &mut banks_client, + &payer, + &non_foundation.pubkey(), + 1_000_000_000, + ) + .await; + + // Non-foundation payer attempts to set link_topologies on the existing link. + // The outer ownership check fails because the payer is neither the + // contributor's owner nor in the foundation allowlist. + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let result = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + code: None, + contributor_pk: None, + tunnel_type: None, + bandwidth: None, + mtu: None, + delay_ns: None, + jitter_ns: None, + delay_override_ns: None, + status: None, + desired_status: None, + tunnel_id: None, + tunnel_net: None, + use_onchain_allocation: false, + link_topologies: Some(vec![unicast_default_pda]), + unicast_drained: None, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &non_foundation, + ) + .await; + + let error_string = format!("{:?}", result.unwrap_err()); + assert!( + error_string.contains("Custom(8)"), + "Expected NotAllowed error (Custom(8)), got: {}", + error_string + ); +} From ae55268b4d328b4155cd7e827b69cc6d266861fe Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 6 Apr 2026 14:39:16 -0500 Subject: [PATCH 41/49] smartcontract: fix mtu values in link_wan_test fixtures; add topology imports --- .../tests/link_wan_test.rs | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index 66abee54c4..9636314dd8 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -5,6 +5,7 @@ use doublezero_serviceability::{ contributor::create::ContributorCreateArgs, device::interface::{update::DeviceInterfaceUpdateArgs, DeviceInterfaceUnlinkArgs}, link::{activate::*, create::*, delete::*, sethealth::LinkSetHealthArgs, update::*}, + topology::create::TopologyCreateArgs, *, }, resource::ResourceType, @@ -16,6 +17,7 @@ use doublezero_serviceability::{ InterfaceCYOA, InterfaceDIA, InterfaceStatus, InterfaceType, LoopbackType, RoutingMode, }, link::*, + topology::TopologyConstraint, }, }; use globalconfig::set::SetGlobalConfigArgs; @@ -231,7 +233,7 @@ async fn test_wan_link() { bandwidth: 0, ip_net: None, cir: 0, - mtu: 1500, + mtu: 9000, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -362,7 +364,7 @@ async fn test_wan_link() { bandwidth: 0, ip_net: None, cir: 0, - mtu: 1500, + mtu: 9000, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -610,7 +612,7 @@ async fn test_wan_link() { contributor_pk: Some(contributor_pubkey), tunnel_type: Some(LinkLinkType::WAN), bandwidth: Some(20000000000), - mtu: Some(8900), + mtu: Some(9000), delay_ns: Some(1000000), jitter_ns: Some(100000), delay_override_ns: Some(0), @@ -639,7 +641,7 @@ async fn test_wan_link() { assert_eq!(tunnel_la.account_type, AccountType::Link); assert_eq!(tunnel_la.code, "la2".to_string()); assert_eq!(tunnel_la.bandwidth, 20000000000); - assert_eq!(tunnel_la.mtu, 8900); + assert_eq!(tunnel_la.mtu, 9000); assert_eq!(tunnel_la.delay_ns, 1000000); assert_eq!(tunnel_la.status, LinkStatus::Activated); assert_eq!(tunnel_la.desired_status, LinkDesiredStatus::Activated); @@ -823,7 +825,7 @@ async fn test_wan_link() { assert_eq!(tunnel_la.account_type, AccountType::Link); assert_eq!(tunnel_la.code, "la2".to_string()); assert_eq!(tunnel_la.bandwidth, 20000000000); - assert_eq!(tunnel_la.mtu, 8900); + assert_eq!(tunnel_la.mtu, 9000); assert_eq!(tunnel_la.delay_ns, 1000000); assert_eq!(tunnel_la.status, LinkStatus::Deleting); @@ -1056,7 +1058,7 @@ async fn test_wan_link_rejects_cyoa_interface() { bandwidth: 0, ip_net: Some("100.1.0.0/31".parse().unwrap()), cir: 0, - mtu: 1500, + mtu: 9000, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -1149,7 +1151,7 @@ async fn test_wan_link_rejects_cyoa_interface() { bandwidth: 0, ip_net: None, cir: 0, - mtu: 1500, + mtu: 9000, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -1249,7 +1251,7 @@ async fn test_wan_link_rejects_cyoa_interface() { loopback_type: None, bandwidth: None, cir: None, - mtu: None, + mtu: Some(9000), routing_mode: None, vlan_id: None, user_tunnel_endpoint: None, @@ -1277,7 +1279,7 @@ async fn test_wan_link_rejects_cyoa_interface() { loopback_type: None, bandwidth: None, cir: None, - mtu: None, + mtu: Some(1500), routing_mode: None, vlan_id: None, user_tunnel_endpoint: None, @@ -1341,7 +1343,7 @@ async fn test_wan_link_rejects_cyoa_interface() { loopback_type: None, bandwidth: None, cir: None, - mtu: None, + mtu: Some(9000), routing_mode: None, vlan_id: None, user_tunnel_endpoint: None, @@ -1398,7 +1400,7 @@ async fn test_wan_link_rejects_cyoa_interface() { loopback_type: None, bandwidth: None, cir: None, - mtu: None, + mtu: Some(1500), routing_mode: None, vlan_id: None, user_tunnel_endpoint: None, @@ -1623,7 +1625,7 @@ async fn test_cannot_set_cyoa_on_linked_interface() { bandwidth: 0, ip_net: None, cir: 0, - mtu: 1500, + mtu: 9000, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -1714,7 +1716,7 @@ async fn test_cannot_set_cyoa_on_linked_interface() { bandwidth: 0, ip_net: None, cir: 0, - mtu: 1500, + mtu: 9000, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -2088,7 +2090,7 @@ async fn setup_link_env() -> ( bandwidth: 0, ip_net: None, cir: 0, - mtu: 1500, + mtu: 9000, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -2176,7 +2178,7 @@ async fn setup_link_env() -> ( bandwidth: 0, ip_net: None, cir: 0, - mtu: 1500, + mtu: 9000, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -2664,7 +2666,6 @@ async fn test_link_activation_fails_without_unicast_default() { error_string ); } -} #[tokio::test] async fn test_link_topology_cap_at_8_rejected() { From d224ac3db06fdda546598712c930fb16a0e66434 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 6 Apr 2026 15:50:28 -0500 Subject: [PATCH 42/49] sdk/go: fix parse_valid_link test and controller findLink after LinkTopologies addition --- .../controller/internal/controller/server.go | 12 ++++++------ smartcontract/sdk/go/serviceability/client_test.go | 4 ++++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/controlplane/controller/internal/controller/server.go b/controlplane/controller/internal/controller/server.go index fcd23c048a..37b51569b2 100644 --- a/controlplane/controller/internal/controller/server.go +++ b/controlplane/controller/internal/controller/server.go @@ -348,22 +348,22 @@ func (c *Controller) updateStateCache(ctx context.Context) error { cache.Ipv4BgpPeers = append(cache.Ipv4BgpPeers, candidateIpv4BgpPeer) // determine if interface is in an onchain link and assign metrics - findLink := func(intf Interface) serviceability.Link { - for _, link := range links { + findLink := func(intf Interface) *serviceability.Link { + for i, link := range links { if d.PubKey == base58.Encode(link.SideAPubKey[:]) && intf.Name == link.SideAIfaceName { - return link + return &links[i] } if d.PubKey == base58.Encode(link.SideZPubKey[:]) && intf.Name == link.SideZIfaceName { - return link + return &links[i] } } - return serviceability.Link{} + return nil } for i, iface := range d.Interfaces { link := findLink(iface) - if link == (serviceability.Link{}) || (link.Status != serviceability.LinkStatusActivated && link.Status != serviceability.LinkStatusSoftDrained && link.Status != serviceability.LinkStatusHardDrained) { + if link == nil || (link.Status != serviceability.LinkStatusActivated && link.Status != serviceability.LinkStatusSoftDrained && link.Status != serviceability.LinkStatusHardDrained) { d.Interfaces[i].IsLink = false d.Interfaces[i].Metric = 0 d.Interfaces[i].LinkStatus = serviceability.LinkStatusPending diff --git a/smartcontract/sdk/go/serviceability/client_test.go b/smartcontract/sdk/go/serviceability/client_test.go index dbae809c77..18c18851af 100644 --- a/smartcontract/sdk/go/serviceability/client_test.go +++ b/smartcontract/sdk/go/serviceability/client_test.go @@ -391,6 +391,10 @@ func TestSDK_Serviceability_GetProgramData(t *testing.T) { ContributorPubKey: [32]byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f}, SideAIfaceName: "switch1/1/1", SideZIfaceName: "lo0", + LinkHealth: LinkHealth(173), + LinkDesiredStatus: LinkDesiredStatus(37), + LinkFlags: 118, + LinkTopologies: nil, PubKey: pubkeys[5], }, }, From 4e6154601e3b3cea3f05778bb791b1e6bca64eb3 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 6 Apr 2026 16:47:16 -0500 Subject: [PATCH 43/49] smartcontract: restore start-test.sh to match main --- smartcontract/test/start-test.sh | 50 +++++++++++++++++--------------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/smartcontract/test/start-test.sh b/smartcontract/test/start-test.sh index 1645adb200..12308ff925 100644 --- a/smartcontract/test/start-test.sh +++ b/smartcontract/test/start-test.sh @@ -12,18 +12,22 @@ mkdir -p ./logs ./target export OPENSSL_NO_VENDOR=1 +if [ "${CARGO_TARGET_DIR}" == "" ]; then + CARGO_TARGET_DIR="../../target" +fi + # Build the program echo "Build the program" -cargo build-sbf --manifest-path ../programs/doublezero-serviceability/Cargo.toml -- -Znext-lockfile-bump --target-dir ../../target/ -cp ../../target/deploy/doublezero_serviceability.so ./target/doublezero_serviceability.so +cargo build-sbf --manifest-path ../programs/doublezero-serviceability/Cargo.toml -- -Znext-lockfile-bump --target-dir ${CARGO_TARGET_DIR} +cp ${CARGO_TARGET_DIR}/deploy/doublezero_serviceability.so ./target/doublezero_serviceability.so #Build the activator echo "Build the activator" -cargo build --manifest-path ../../activator/Cargo.toml --target-dir ../../target/ ; cp ../../target/debug/doublezero-activator ./target/ +cargo build --manifest-path ../../activator/Cargo.toml --target-dir ${CARGO_TARGET_DIR} ; cp ${CARGO_TARGET_DIR}/debug/doublezero-activator ./target/ #Build the activator echo "Build the client" -cargo build --manifest-path ../../client/doublezero/Cargo.toml --target-dir ../../target/ ; cp ../../target/debug/doublezero ./target/ +cargo build --manifest-path ../../client/doublezero/Cargo.toml --target-dir ${CARGO_TARGET_DIR} ; cp ${CARGO_TARGET_DIR}/debug/doublezero ./target/ # Configure to connect to localnet solana config set --url http://127.0.0.1:8899 @@ -125,24 +129,24 @@ echo "Creating devices" ### Initialize device interfaces echo "Creating device interfaces" -./target/doublezero device interface create la2-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create la2-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create la2-dz01 "Switch1/1/3" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create ny5-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create ny5-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create ld4-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create ld4-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create ld4-dz01 "Switch1/1/3" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create frk-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create frk-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create sg1-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create sg1-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create ty2-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create ty2-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create pit-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create pit-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create ams-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create ams-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create la2-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create la2-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create la2-dz01 "Switch1/1/3" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create ny5-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create ny5-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create ld4-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create ld4-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create ld4-dz01 "Switch1/1/3" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create frk-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create frk-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create sg1-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create sg1-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create ty2-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create ty2-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create pit-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create pit-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create ams-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create ams-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w ### Initialize links echo "Creating internal links" @@ -164,7 +168,7 @@ echo "Creating devices" ### Initialize device interfaces echo "Creating device interfaces" -./target/doublezero device interface create la2-dz02 "Switch1/1/1" --bandwidth "1G" --cir "1G" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create la2-dz02 "Switch1/1/1" --bandwidth "1G" --cir "1G" --mtu 9000 --routing-mode static -w ### Initialize links echo "Creating external links" From 81b03d244caa036fc6d0a6298dd268a05caf2fa5 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 6 Apr 2026 22:31:17 -0500 Subject: [PATCH 44/49] smartcontract: fix post-rebase compile errors in cli link commands and activator tests --- activator/src/processor.rs | 4 +- smartcontract/cli/src/link/dzx_create.rs | 37 +++++++------ smartcontract/cli/src/link/update.rs | 54 ++++++++++--------- smartcontract/cli/src/link/wan_create.rs | 43 ++++++++------- .../src/state/interface.rs | 4 ++ 5 files changed, 78 insertions(+), 64 deletions(-) diff --git a/activator/src/processor.rs b/activator/src/processor.rs index 86caa72455..2666c7d97b 100644 --- a/activator/src/processor.rs +++ b/activator/src/processor.rs @@ -763,7 +763,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let mut existing_links: HashMap = HashMap::new(); @@ -800,7 +800,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let new_link_cloned = new_link.clone(); diff --git a/smartcontract/cli/src/link/dzx_create.rs b/smartcontract/cli/src/link/dzx_create.rs index 252b228fb4..d3c17a8cd0 100644 --- a/smartcontract/cli/src/link/dzx_create.rs +++ b/smartcontract/cli/src/link/dzx_create.rs @@ -5,7 +5,7 @@ use crate::{ requirements::{CHECK_BALANCE, CHECK_ID_JSON}, validators::{ validate_code, validate_parse_bandwidth, validate_parse_delay_ms, validate_parse_jitter_ms, - validate_parse_mtu, validate_pubkey_or_code, + validate_pubkey_or_code, }, }; use clap::Args; @@ -48,8 +48,8 @@ pub struct CreateDZXLinkCliCommand { /// Bandwidth (required). Accepts values in Kbps, Mbps, or Gbps. #[arg(long, value_parser = validate_parse_bandwidth)] pub bandwidth: u64, - /// MTU (Maximum Transmission Unit) in bytes. - #[arg(long, value_parser = validate_parse_mtu)] + /// MTU (Maximum Transmission Unit) in bytes. Must be 9000. + #[arg(long, default_value_t = 9000)] pub mtu: u32, /// RTT (Round Trip Time) delay in milliseconds. #[arg(long, value_parser = validate_parse_delay_ms)] @@ -126,13 +126,17 @@ impl CreateDZXLinkCliCommand { )); } - if side_a_iface.mtu != 2048 { + if side_a_iface.mtu != 9000 { return Err(eyre!( - "Interface '{}' on side A device has MTU {} but DZX link interfaces must have MTU 2048", + "Interface '{}' on side A device has MTU {} but DZX link interfaces must have MTU 9000", self.side_a_interface, side_a_iface.mtu )); } + if self.mtu != 9000 { + return Err(eyre!("Link MTU must be 9000")); + } + if client .get_link(GetLinkCommand { pubkey_or_code: self.code.clone(), @@ -230,7 +234,7 @@ mod tests { ip_net: "10.2.0.1/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 2048, + mtu: 9000, ..Default::default() } .to_interface()], @@ -275,7 +279,7 @@ mod tests { ip_net: "10.2.0.2/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 2048, + mtu: 9000, ..Default::default() } .to_interface()], @@ -320,7 +324,7 @@ mod tests { ip_net: "10.2.0.3/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 2048, + mtu: 9000, ..Default::default() } .to_interface()], @@ -346,7 +350,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -359,8 +363,7 @@ mod tests { side_z_iface_name: "Ethernet1/2".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, - - link_topologies: Vec::new(), + link_topologies: vec![], link_flags: 0, }; @@ -403,7 +406,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::DZX, bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ns: 10000000000, jitter_ns: 5000000000, side_a_iface_name: "Ethernet1/1".to_string(), @@ -421,7 +424,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -449,7 +452,7 @@ mod tests { side_a: device2_pk.to_string(), side_z: device3_pk.to_string(), bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/2".to_string(), @@ -552,7 +555,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -653,7 +656,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 2048, + mtu: 9000, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -664,7 +667,7 @@ mod tests { assert!(res.is_err()); assert_eq!( res.unwrap_err().to_string(), - "Interface 'Ethernet1/1' on side A device has MTU 1500 but DZX link interfaces must have MTU 2048" + "Interface 'Ethernet1/1' on side A device has MTU 1500 but DZX link interfaces must have MTU 9000" ); } } diff --git a/smartcontract/cli/src/link/update.rs b/smartcontract/cli/src/link/update.rs index 9e25fe3769..e5ea6785a7 100644 --- a/smartcontract/cli/src/link/update.rs +++ b/smartcontract/cli/src/link/update.rs @@ -4,8 +4,8 @@ use crate::{ requirements::{CHECK_BALANCE, CHECK_ID_JSON}, validators::{ validate_code, validate_parse_bandwidth, validate_parse_delay_ms, - validate_parse_delay_override_ms, validate_parse_jitter_ms, validate_parse_mtu, - validate_pubkey, validate_pubkey_or_code, + validate_parse_delay_override_ms, validate_parse_jitter_ms, validate_pubkey, + validate_pubkey_or_code, }, }; use clap::Args; @@ -13,6 +13,7 @@ use doublezero_program_common::types::NetworkV4; use doublezero_sdk::commands::{ contributor::get::GetContributorCommand, link::{get::GetLinkCommand, update::UpdateLinkCommand}, + topology::list::ListTopologyCommand, }; use doublezero_serviceability::state::link::LinkDesiredStatus; use eyre::eyre; @@ -35,8 +36,8 @@ pub struct UpdateLinkCliCommand { /// Updated bandwidth (e.g. 1Gbps, 100Mbps) #[arg(long, value_parser = validate_parse_bandwidth)] pub bandwidth: Option, - /// Updated MTU (Maximum Transmission Unit) in bytes - #[arg(long, value_parser = validate_parse_mtu)] + /// Updated MTU (Maximum Transmission Unit) in bytes. Must be 9000. + #[arg(long)] pub mtu: Option, /// RTT (Round Trip Time) delay in milliseconds #[arg(long, value_parser = validate_parse_delay_ms)] @@ -103,6 +104,12 @@ impl UpdateLinkCliCommand { .transpose() .map_err(|e| eyre!("Invalid status: {e}"))?; + if let Some(mtu) = self.mtu { + if mtu != 9000 { + return Err(eyre!("Link MTU must be 9000")); + } + } + if let Some(ref code) = self.code { if link.code != *code && client @@ -115,19 +122,18 @@ impl UpdateLinkCliCommand { } } - let link_topologies = if let Some(ref topology_name) = self.link_topology { - if topology_name == "default" { - Some(vec![]) - } else { - let (topology_pda, _) = - doublezero_sdk::get_topology_pda(&client.get_program_id(), topology_name); - client - .get_account(topology_pda) - .map_err(|_| eyre::eyre!("Topology '{}' not found", topology_name))?; - Some(vec![topology_pda]) + let link_topologies = match self.link_topology { + None => None, + Some(ref name) if name == "default" => Some(vec![]), + Some(ref name) => { + let topology_map = client.list_topology(ListTopologyCommand)?; + let topology_pk = topology_map + .iter() + .find(|(_, t)| t.name == *name) + .map(|(pk, _)| *pk) + .ok_or_else(|| eyre!("Topology '{}' not found", name))?; + Some(vec![topology_pk]) } - } else { - None }; let signature = client.update_link(UpdateLinkCommand { @@ -217,7 +223,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -229,8 +235,7 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, - - link_topologies: Vec::new(), + link_topologies: vec![], link_flags: 0, }; @@ -244,7 +249,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -256,8 +261,7 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, - - link_topologies: Vec::new(), + link_topologies: vec![], link_flags: 0, }; @@ -297,7 +301,7 @@ mod tests { contributor_pk: Some(contributor_pk), tunnel_type: None, bandwidth: Some(1000000000), - mtu: Some(1500), + mtu: Some(9000), delay_ns: Some(10000000), jitter_ns: Some(5000000), delay_override_ns: None, @@ -318,7 +322,7 @@ mod tests { contributor: Some(contributor_pk.to_string()), tunnel_type: None, bandwidth: Some(1000000000), - mtu: Some(1500), + mtu: Some(9000), delay_ms: Some(10.0), jitter_ms: Some(5.0), delay_override_ms: None, @@ -345,7 +349,7 @@ mod tests { contributor: Some(contributor_pk.to_string()), tunnel_type: None, bandwidth: Some(1000000000), - mtu: Some(1500), + mtu: Some(9000), delay_ms: Some(10.0), jitter_ms: Some(5.0), delay_override_ms: None, diff --git a/smartcontract/cli/src/link/wan_create.rs b/smartcontract/cli/src/link/wan_create.rs index a443f626a8..fc09b3b10b 100644 --- a/smartcontract/cli/src/link/wan_create.rs +++ b/smartcontract/cli/src/link/wan_create.rs @@ -5,7 +5,7 @@ use crate::{ requirements::{CHECK_BALANCE, CHECK_ID_JSON}, validators::{ validate_code, validate_parse_bandwidth, validate_parse_delay_ms, validate_parse_jitter_ms, - validate_parse_mtu, validate_pubkey_or_code, + validate_pubkey_or_code, }, }; use clap::Args; @@ -51,8 +51,8 @@ pub struct CreateWANLinkCliCommand { /// Bandwidth (required). Accepts values in Kbps, Mbps, or Gbps. #[arg(long, value_parser = validate_parse_bandwidth)] pub bandwidth: u64, - /// MTU (Maximum Transmission Unit) in bytes. - #[arg(long, value_parser = validate_parse_mtu)] + /// MTU (Maximum Transmission Unit) in bytes. Must be 9000. + #[arg(long, default_value_t = 9000)] pub mtu: u32, /// RTT (Round Trip Time) delay in milliseconds. #[arg(long, value_parser = validate_parse_delay_ms)] @@ -129,9 +129,9 @@ impl CreateWANLinkCliCommand { )); } - if side_a_iface.mtu != 2048 { + if side_a_iface.mtu != 9000 { return Err(eyre!( - "Interface '{}' on side A device has MTU {} but WAN link interfaces must have MTU 2048", + "Interface '{}' on side A device has MTU {} but WAN link interfaces must have MTU 9000", self.side_a_interface, side_a_iface.mtu )); } @@ -171,13 +171,17 @@ impl CreateWANLinkCliCommand { )); } - if side_z_iface.mtu != 2048 { + if side_z_iface.mtu != 9000 { return Err(eyre!( - "Interface '{}' on side Z device has MTU {} but WAN link interfaces must have MTU 2048", + "Interface '{}' on side Z device has MTU {} but WAN link interfaces must have MTU 9000", self.side_z_interface, side_z_iface.mtu )); } + if self.mtu != 9000 { + return Err(eyre!("Link MTU must be 9000")); + } + if client .get_link(GetLinkCommand { pubkey_or_code: self.code.clone(), @@ -277,7 +281,7 @@ mod tests { ip_net: "10.2.0.1/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 2048, + mtu: 9000, ..Default::default() } .to_interface()], @@ -322,7 +326,7 @@ mod tests { ip_net: "10.2.0.2/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 2048, + mtu: 9000, ..Default::default() } .to_interface()], @@ -367,7 +371,7 @@ mod tests { ip_net: "10.2.0.3/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 2048, + mtu: 9000, ..Default::default() } .to_interface()], @@ -393,7 +397,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -406,8 +410,7 @@ mod tests { side_z_iface_name: "Ethernet1/2".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, - - link_topologies: Vec::new(), + link_topologies: vec![], link_flags: 0, }; @@ -450,7 +453,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ns: 10000000000, jitter_ns: 5000000000, side_a_iface_name: "Ethernet1/1".to_string(), @@ -468,7 +471,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -497,7 +500,7 @@ mod tests { side_a: device2_pk.to_string(), side_z: device3_pk.to_string(), bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/2".to_string(), @@ -635,7 +638,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -723,7 +726,7 @@ mod tests { name: "Ethernet1/2".to_string(), interface_type: InterfaceType::Physical, loopback_type: LoopbackType::None, - mtu: 2048, + mtu: 9000, vlan_id: 16, ip_net: "10.2.0.2/24".parse().unwrap(), node_segment_idx: 0, @@ -770,7 +773,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 2048, + mtu: 9000, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -782,7 +785,7 @@ mod tests { assert!(res.is_err()); assert_eq!( res.unwrap_err().to_string(), - "Interface 'Ethernet1/1' on side A device has MTU 1500 but WAN link interfaces must have MTU 2048" + "Interface 'Ethernet1/1' on side A device has MTU 1500 but WAN link interfaces must have MTU 9000" ); } } diff --git a/smartcontract/programs/doublezero-serviceability/src/state/interface.rs b/smartcontract/programs/doublezero-serviceability/src/state/interface.rs index c78143b6ca..5fc3613578 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/interface.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/interface.rs @@ -4,6 +4,10 @@ use doublezero_program_common::{types::NetworkV4, validate_iface}; use solana_program::{msg, program_error::ProgramError}; use std::{fmt, str::FromStr}; +pub const LINK_MTU: u32 = 9000; +pub const INTERFACE_MTU: u16 = 9000; +pub const CYOA_DIA_INTERFACE_MTU: u16 = 1500; + #[repr(u8)] #[derive(BorshSerialize, BorshDeserialize, Debug, Copy, Clone, PartialEq, Default)] #[borsh(use_discriminant = true)] From 25b32e76883286732bc41698bfe1710c6eb8a95b Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 6 Apr 2026 23:00:36 -0500 Subject: [PATCH 45/49] smartcontract: add RFC-18 flex-algo CHANGELOG entries --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bd6c54bc5..688db4355e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,19 @@ All notable changes to this project will be documented in this file. - Add `twamp-debug` diagnostic tool for testing kernel timestamping support on switches; sends real TWAMP probes to verify which SO_TIMESTAMPING modes (RX/TX software/hardware/sched) actually deliver timestamps, and reports RTT statistics comparing userspace vs kernel timestamp sources - E2E Tests - Switch backward compatibility test to install versioned CLI binaries from GitHub releases instead of Cloudsmith apt repos; version enumeration now uses the GitHub API directly from Go rather than querying apt-cache inside the container +- Smartcontract + - Add `TopologyInfo` onchain account for IS-IS flex-algo link classification: auto-assigned TE admin-group bit (1–62), derived flex-algo number (128 + bit), and constraint type (`include-any`/`include-all`); capped at 62 topologies via `AdminGroupBits` resource extension + - Add `link_topologies: Vec` (capped at 8) and `link_flags: u8` (bit 0 = unicast-drained) to the `Link` account + - Add `include_topologies` to the `Tenant` account for topology-filtered routing opt-in + - Enforce UNICAST-DEFAULT topology existence as a precondition for link activation +- CLI + - Add `doublezero link topology` subcommands: `create`, `delete`, `clear`, `list`, `backfill` + - Add `--link-topology ` and `--unicast-drained ` flags to `doublezero link update` + - Add `--topology ` filter to `doublezero link list` (`default` = untagged links) + - Add `--include-topologies ` flag to `doublezero tenant update` + - Add `doublezero-admin migrate flex-algo [--dry-run]` to tag existing links with UNICAST-DEFAULT and backfill node segments +- SDK + - Update Go, Python, and TypeScript SDKs with `TopologyInfo` deserialization and new `link_topologies`, `link_flags`, and `include_topologies` fields ## [v0.15.0](https://github.com/malbeclabs/doublezero/compare/client/v0.14.0...client/v0.15.0) - 2026-03-27 From 9d63cf64927dd5f50afa5cf5cd855594e036ae73 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 7 Apr 2026 10:29:28 -0500 Subject: [PATCH 46/49] smartcontract: fix incorrect MTU in test_update_cyoa_interface_with_invalid_sibling --- .../tests/delete_cyoa_interface_test.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs b/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs index 376096ca18..b570eee720 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs @@ -414,7 +414,7 @@ async fn test_update_cyoa_interface_with_invalid_sibling() { program_id, DoubleZeroInstruction::UpdateDeviceInterface(DeviceInterfaceUpdateArgs { name: "ethernet1".to_string(), - mtu: Some(9000), + mtu: Some(1500), // CYOA interfaces require CYOA_DIA_INTERFACE_MTU = 1500 ..Default::default() }), vec![ @@ -438,7 +438,7 @@ async fn test_update_cyoa_interface_with_invalid_sibling() { .unwrap(); let updated_iface = device.find_interface("Ethernet1").unwrap().1; - assert_eq!(updated_iface.mtu, 9000, "MTU should be updated to 9000"); + assert_eq!(updated_iface.mtu, 1500, "MTU should remain 1500 for CYOA interface"); assert_eq!( updated_iface.ip_net, "63.243.225.62/30".parse().unwrap(), From 43191cf9c46ade6d3c240f90cef9b828e5a472a1 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 7 Apr 2026 11:22:19 -0500 Subject: [PATCH 47/49] sdk/ts: fix deserializeInterface to read flex_algo_node_segments for V2 (version 1); rustfmt --- .../typescript/serviceability/state.ts | 20 +++++++--------- .../src/instructions.rs | 24 +++++++++---------- .../doublezero-serviceability/src/pda.rs | 11 ++++----- .../tests/delete_cyoa_interface_test.rs | 5 +++- 4 files changed, 30 insertions(+), 30 deletions(-) diff --git a/sdk/serviceability/typescript/serviceability/state.ts b/sdk/serviceability/typescript/serviceability/state.ts index 01b5262355..34b70edd5d 100644 --- a/sdk/serviceability/typescript/serviceability/state.ts +++ b/sdk/serviceability/typescript/serviceability/state.ts @@ -534,18 +534,16 @@ function deserializeInterface(r: DefensiveReader): DeviceInterface { iface.ipNet = r.readNetworkV4(); iface.nodeSegmentIdx = r.readU16(); iface.userTunnelEndpoint = r.readBool(); - if (iface.version === 2) { - // V3 - const segCount = r.readU32(); - const flexAlgoNodeSegments: FlexAlgoNodeSegment[] = []; - for (let i = 0; i < segCount; i++) { - flexAlgoNodeSegments.push({ - topology: readPubkey(r), - nodeSegmentIdx: r.readU16(), - }); - } - iface.flexAlgoNodeSegments = flexAlgoNodeSegments; + // flex_algo_node_segments is part of V2 (version byte 1) and later + const segCount = r.readU32(); + const flexAlgoNodeSegments: FlexAlgoNodeSegment[] = []; + for (let i = 0; i < segCount; i++) { + flexAlgoNodeSegments.push({ + topology: readPubkey(r), + nodeSegmentIdx: r.readU16(), + }); } + iface.flexAlgoNodeSegments = flexAlgoNodeSegments; } return iface; diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index 4c850673a0..38a0081c21 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -505,12 +505,12 @@ impl DoubleZeroInstruction { Self::Deprecated102() => "Deprecated102".to_string(), Self::Deprecated103() => "Deprecated103".to_string(), - Self::CreateIndex(_) => "CreateIndex".to_string(), // variant 104 - Self::DeleteIndex(_) => "DeleteIndex".to_string(), // variant 105 + Self::CreateIndex(_) => "CreateIndex".to_string(), // variant 104 + Self::DeleteIndex(_) => "DeleteIndex".to_string(), // variant 105 Self::SetUserBGPStatus(_) => "SetUserBGPStatus".to_string(), // variant 106 - Self::CreateTopology(_) => "CreateTopology".to_string(), // variant 107 - Self::DeleteTopology(_) => "DeleteTopology".to_string(), // variant 108 - Self::ClearTopology(_) => "ClearTopology".to_string(), // variant 109 + Self::CreateTopology(_) => "CreateTopology".to_string(), // variant 107 + Self::DeleteTopology(_) => "DeleteTopology".to_string(), // variant 108 + Self::ClearTopology(_) => "ClearTopology".to_string(), // variant 109 Self::BackfillTopology(_) => "BackfillTopology".to_string(), // variant 110 } } @@ -639,13 +639,13 @@ impl DoubleZeroInstruction { Self::Deprecated102() => String::new(), Self::Deprecated103() => String::new(), - Self::CreateIndex(args) => format!("{args:?}"), // variant 104 - Self::DeleteIndex(args) => format!("{args:?}"), // variant 105 - Self::SetUserBGPStatus(args) => format!("{args:?}"), // variant 106 - Self::CreateTopology(args) => format!("{args:?}"), // variant 107 - Self::DeleteTopology(args) => format!("{args:?}"), // variant 108 - Self::ClearTopology(args) => format!("{args:?}"), // variant 109 - Self::BackfillTopology(args) => format!("{args:?}"), // variant 110 + Self::CreateIndex(args) => format!("{args:?}"), // variant 104 + Self::DeleteIndex(args) => format!("{args:?}"), // variant 105 + Self::SetUserBGPStatus(args) => format!("{args:?}"), // variant 106 + Self::CreateTopology(args) => format!("{args:?}"), // variant 107 + Self::DeleteTopology(args) => format!("{args:?}"), // variant 108 + Self::ClearTopology(args) => format!("{args:?}"), // variant 109 + Self::BackfillTopology(args) => format!("{args:?}"), // variant 110 } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/pda.rs b/smartcontract/programs/doublezero-serviceability/src/pda.rs index f112eb2db2..0283657906 100644 --- a/smartcontract/programs/doublezero-serviceability/src/pda.rs +++ b/smartcontract/programs/doublezero-serviceability/src/pda.rs @@ -5,12 +5,11 @@ use solana_program::pubkey::Pubkey; use crate::{ seeds::{ SEED_ACCESS_PASS, SEED_ADMIN_GROUP_BITS, SEED_CONFIG, SEED_CONTRIBUTOR, SEED_DEVICE, - SEED_DEVICE_TUNNEL_BLOCK, SEED_DZ_PREFIX_BLOCK, SEED_EXCHANGE, SEED_GLOBALSTATE, SEED_INDEX, - SEED_LINK, - SEED_LINK_IDS, SEED_LOCATION, SEED_MULTICASTGROUP_BLOCK, SEED_MULTICAST_GROUP, - SEED_MULTICAST_PUBLISHER_BLOCK, SEED_PERMISSION, SEED_PREFIX, SEED_PROGRAM_CONFIG, - SEED_SEGMENT_ROUTING_IDS, SEED_TENANT, SEED_TOPOLOGY, SEED_TUNNEL_IDS, SEED_USER, - SEED_USER_TUNNEL_BLOCK, SEED_VRF_IDS, + SEED_DEVICE_TUNNEL_BLOCK, SEED_DZ_PREFIX_BLOCK, SEED_EXCHANGE, SEED_GLOBALSTATE, + SEED_INDEX, SEED_LINK, SEED_LINK_IDS, SEED_LOCATION, SEED_MULTICASTGROUP_BLOCK, + SEED_MULTICAST_GROUP, SEED_MULTICAST_PUBLISHER_BLOCK, SEED_PERMISSION, SEED_PREFIX, + SEED_PROGRAM_CONFIG, SEED_SEGMENT_ROUTING_IDS, SEED_TENANT, SEED_TOPOLOGY, SEED_TUNNEL_IDS, + SEED_USER, SEED_USER_TUNNEL_BLOCK, SEED_VRF_IDS, }, state::user::UserType, }; diff --git a/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs b/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs index b570eee720..08baafaed7 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs @@ -438,7 +438,10 @@ async fn test_update_cyoa_interface_with_invalid_sibling() { .unwrap(); let updated_iface = device.find_interface("Ethernet1").unwrap().1; - assert_eq!(updated_iface.mtu, 1500, "MTU should remain 1500 for CYOA interface"); + assert_eq!( + updated_iface.mtu, 1500, + "MTU should remain 1500 for CYOA interface" + ); assert_eq!( updated_iface.ip_net, "63.243.225.62/30".parse().unwrap(), From b9f2729f707bfb7f27243568aad20c364bd7884d Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 6 Apr 2026 14:15:45 -0500 Subject: [PATCH 48/49] activator: automatically backfill flex-algo node segments on topology create and loopback activation --- activator/src/process/device.rs | 14 +++++++ activator/src/process/iface_mgr.rs | 65 ++++++++++++++++++++++++++++-- activator/src/processor.rs | 47 ++++++++++++++++++++- 3 files changed, 122 insertions(+), 4 deletions(-) diff --git a/activator/src/process/device.rs b/activator/src/process/device.rs index 1dcb4addcf..914252cbb2 100644 --- a/activator/src/process/device.rs +++ b/activator/src/process/device.rs @@ -437,6 +437,13 @@ mod tests { ) .returning(|_, _| Ok(Signature::new_unique())); + // After activating a vpnv4 loopback, the interface manager queries existing topologies + // to auto-backfill flex-algo node segments. + client + .expect_gets() + .with(predicate::eq(AccountType::Topology)) + .returning(|_| Ok(HashMap::new())); + // interfaces get checked on activated devices device.status = DeviceStatus::Activated; @@ -662,6 +669,13 @@ mod tests { ) .returning(|_, _| Ok(Signature::new_unique())); + // After activating a vpnv4 loopback, the interface manager queries existing topologies + // to auto-backfill flex-algo node segments. + client + .expect_gets() + .with(predicate::eq(AccountType::Topology)) + .returning(|_| Ok(HashMap::new())); + process_device_event( &client, &pubkey, diff --git a/activator/src/process/iface_mgr.rs b/activator/src/process/iface_mgr.rs index 02d0f1733c..b3d068b750 100644 --- a/activator/src/process/iface_mgr.rs +++ b/activator/src/process/iface_mgr.rs @@ -1,9 +1,12 @@ use crate::{idallocator::IDAllocator, ipblockallocator::IPBlockAllocator}; use doublezero_program_common::types::NetworkV4; use doublezero_sdk::{ - commands::device::interface::{ - activate::ActivateDeviceInterfaceCommand, reject::RejectDeviceInterfaceCommand, - remove::RemoveDeviceInterfaceCommand, unlink::UnlinkDeviceInterfaceCommand, + commands::{ + device::interface::{ + activate::ActivateDeviceInterfaceCommand, reject::RejectDeviceInterfaceCommand, + remove::RemoveDeviceInterfaceCommand, unlink::UnlinkDeviceInterfaceCommand, + }, + topology::{backfill::BackfillTopologyCommand, list::ListTopologyCommand}, }, CurrentInterfaceVersion, Device, DoubleZeroClient, InterfaceStatus, InterfaceType, LoopbackType, @@ -11,6 +14,54 @@ use doublezero_sdk::{ use log::{error, info}; use solana_sdk::pubkey::Pubkey; +/// After a vpnv4 loopback is activated, automatically backfill flex-algo node segments +/// on that device for every topology that currently exists on the ledger. +fn backfill_device_for_all_topologies( + client: &dyn DoubleZeroClient, + device_pubkey: &Pubkey, + device_code: &str, +) { + let topologies = match ListTopologyCommand.execute(client) { + Ok(t) => t, + Err(e) => { + error!( + "Failed to list topologies for backfill after loopback activation on {device_code}: {e}" + ); + return; + } + }; + + if topologies.is_empty() { + return; + } + + info!( + "Backfilling {} topology/topologies for device {device_code} after vpnv4 loopback activation", + topologies.len() + ); + + for topology in topologies.values() { + let cmd = BackfillTopologyCommand { + name: topology.name.clone(), + device_pubkeys: vec![*device_pubkey], + }; + match cmd.execute(client) { + Ok(sig) => { + info!( + "Backfilled topology '{}' for device {device_code}: {sig}", + topology.name + ); + } + Err(e) => { + error!( + "Failed to backfill topology '{}' for device {device_code}: {e}", + topology.name + ); + } + } + } +} + /// Stateless interface manager for onchain allocation mode. /// Does not use local allocators - all allocation is handled by the smart contract. pub struct InterfaceMgrStateless<'a> { @@ -74,6 +125,10 @@ impl<'a> InterfaceMgrStateless<'a> { &NetworkV4::default(), 0, ); + + if iface.loopback_type == LoopbackType::Vpnv4 { + backfill_device_for_all_topologies(self.client, device_pubkey, &device.code); + } } /// Handle interface deletion (stateless mode - no local deallocation) @@ -254,6 +309,10 @@ impl<'a> InterfaceMgr<'a> { &iface.ip_net, iface.node_segment_idx, ); + + if iface.loopback_type == LoopbackType::Vpnv4 { + backfill_device_for_all_topologies(self.client, device_pubkey, &device.code); + } } /// Handle interface deletion and resource cleanup diff --git a/activator/src/processor.rs b/activator/src/processor.rs index 2666c7d97b..529d08356b 100644 --- a/activator/src/processor.rs +++ b/activator/src/processor.rs @@ -18,7 +18,7 @@ use doublezero_sdk::{ commands::{ device::list::ListDeviceCommand, exchange::list::ListExchangeCommand, link::list::ListLinkCommand, location::list::ListLocationCommand, - user::list::ListUserCommand, + topology::backfill::BackfillTopologyCommand, user::list::ListUserCommand, }, doublezeroclient::DoubleZeroClient, AccountData, Device, DeviceStatus, Exchange, GetGlobalConfigCommand, InterfaceType, Link, @@ -65,6 +65,35 @@ pub struct ProcessorStateless { multicastgroups: MulticastGroupMap, } +/// Solana transaction account limit minus the 4 fixed accounts in BackfillTopologyCommand +/// (topology PDA, segment_routing_ids PDA, globalstate, payer). +const BACKFILL_BATCH_SIZE: usize = 28; + +/// Backfill flex-algo node segments on a set of devices for a given topology, batching +/// device pubkeys to stay within Solana's per-transaction account limit. +fn backfill_topology_for_devices( + client: &dyn DoubleZeroClient, + topology_name: &str, + device_pubkeys: &[Pubkey], +) { + if device_pubkeys.is_empty() { + return; + } + info!( + "Backfilling topology '{topology_name}' for {} device(s)", + device_pubkeys.len() + ); + for chunk in device_pubkeys.chunks(BACKFILL_BATCH_SIZE) { + let cmd = BackfillTopologyCommand { + name: topology_name.to_string(), + device_pubkeys: chunk.to_vec(), + }; + if let Err(e) = cmd.execute(client) { + error!("Failed to backfill topology '{topology_name}' for device batch: {e}"); + } + } +} + /// Reserve segment routing IDs and loopback IPs for devices that have active allocations. /// Devices in Activated, Drained, DeviceProvisioning, or LinkProvisioning states all /// hold allocated addresses that must not be handed out to new devices. @@ -340,6 +369,14 @@ impl Processor { error!("Error processing access pass event: {e}"); }); } + AccountData::Topology(topology) => { + let device_pubkeys: Vec = self.devices.keys().copied().collect(); + backfill_topology_for_devices( + self.client.as_ref(), + &topology.name, + &device_pubkeys, + ); + } _ => {} }; metrics::counter!("doublezero_activator_event_handled").increment(1); @@ -439,6 +476,14 @@ impl ProcessorStateless { error!("Error processing access pass event: {e}"); }); } + AccountData::Topology(topology) => { + let device_pubkeys: Vec = self.devices.keys().copied().collect(); + backfill_topology_for_devices( + self.client.as_ref(), + &topology.name, + &device_pubkeys, + ); + } _ => {} }; metrics::counter!("doublezero_activator_event_handled").increment(1); From 267ba067c83287deefd090647dd1911d34b2beef Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 6 Apr 2026 23:05:46 -0500 Subject: [PATCH 49/49] activator: add RFC-18 flex-algo CHANGELOG entry --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14c88ddf39..d4e7234220 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,9 @@ All notable changes to this project will be documented in this file. - Add `doublezero-admin migrate flex-algo [--dry-run]` to tag existing links with UNICAST-DEFAULT and backfill node segments - SDK - Update Go, Python, and TypeScript SDKs with `TopologyInfo` deserialization and new `link_topologies`, `link_flags`, and `include_topologies` fields +- Activator + - Automatically backfill flex-algo node segment IDs for all activated devices when a new `TopologyInfo` account is created onchain + - Automatically backfill existing topologies' node segments when a Vpnv4 loopback interface is activated on a device - 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