Skip to content

cos: enforce the inet-precedence classifier end to end (#6847) - #6877

Open
psaab wants to merge 6 commits into
masterfrom
fix/6847-rust
Open

cos: enforce the inet-precedence classifier end to end (#6847)#6877
psaab wants to merge 6 commits into
masterfrom
fix/6847-rust

Conversation

@psaab

@psaab psaab commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Summary

class-of-service classifiers inet-precedence was accepted by the config
compiler and then did nothing: the classifier was parsed, validated and stored,
but no dataplane path ever consulted it, so every packet kept its default
forwarding class. This closes that gap end to end — config half plus the Rust
dataplane enforcement.

Why the scope is what it is

Three discarded-binding gaps had to be closed together or the feature stays
inert: coSInterfaceUnitHasBinding, the #1183 useful_cos_state gate, and the
ba_reclassify gate each independently dropped the classifier before it could
take effect. A PR containing only the classifier plumbing would ship exactly
the accepted-but-does-nothing state this issue exists to close.

loss-priority is included for the same reason one level down: without it,
loss-priority high compiles and silently applies the LOW rewrite — an
accepted-but-inert sub-knob inside the fix for accepted-but-inert.

Validation

  • Full go test ./... — exit 0.
  • go test ./pkg/refactoraudit/ — exit 0; no file crossed a heatmap tier.
  • Rust suite measured against the base commit rather than in isolation, so the
    known process-global HA counter flake could not be mistaken for a regression:
    base ad9591177 4233 passed / 1 failed, this branch 4241 passed / 1
    failed
    — the same single test, and the +8 are exactly this branch's eight
    new tests. The diff touches zero bytes under afxdp/ha. That flake is fixed
    separately by test(ha): scope sync-import refusal counters per Coordinator #6862.
  • 14 of 14 new guards mutation-proved, each with a verified byte-snapshot
    restore.
  • protocol_wire_v1.json regenerated in its own commit: 3 additive keys, 0
    removed, 0 changed
    — checked on both the Rust and Go sides, since a
    one-sided field is a no-transit risk.

Measured negative result, recorded so it is not mistaken for redundancy

The out-of-range check exists at two sites whose key sets are always identical,
so mutating either one alone leaves the suite green. Both are kept
deliberately; do not delete the second as dead.

Outstanding

Loss-cluster smoke is owed and is the maintainer's to run — this touches the
CoS classifier path.

Closes #6847

Paul Saab and others added 2 commits August 5, 2026 11:42
DO NOT MERGE THIS COMMIT ALONE. It adds the unit-level binding site
without the enforcement behind it, so an operator can now bind an
inet-precedence classifier and it will silently do nothing. That is
WORSE than master, where the bind line is rejected outright. The Rust
classify arm and the wire fields must land before this is mergeable.

Scope finding that changed the plan. The issue described inet-precedence
as accepted-but-inert, implying only a dataplane arm was missing. In
fact the unit-level `classifiers` schema node had no `inet-precedence`
child at all: the classifier was definable at the top level and NOT
bindable, so `set class-of-service interfaces <if> unit <n> classifiers
inet-precedence <name>` was rejected by the schema. An imported vSRX
config fails at the bind line, not silently at runtime.

Landed in this commit (Go config layer):

  - CoSINetPrecedenceClassifier / ...Entry types and the keyed
    INetPrecedenceClassifierDefs map. #4316 recorded only classifier
    NAMES because nothing consumed them; the entries are now compiled.
  - The unit-level schema binding site (new) and the matching
    CoSInterfaceUnit.INetPrecedenceClassifier field.
  - collectCoSINetPrecedenceCodePoints: the 0..7 domain with its own
    message wording rather than borrowing the 802.1p collector's, since
    it is a different field of the same width.
  - The classifier compile, mirroring dscp including the
    lenientCoSNumericCodePoint downgrade.
  - interface -> unit inheritance, mirroring the dscp / 802.1p fallbacks.
  - validateCoSUnitClassifierConflict: a unit binding BOTH dscp and
    inet-precedence is rejected at commit. The two read the same IPv4 TOS
    byte -- precedence is its top 3 bits -- so binding both is a
    contradiction, not a composition, and the BA chain consults DSCP
    first, which would leave the inet-precedence binding silently dead.
    Paired with lenientCoSUnitClassifierConflict so the tolerant
    Load/SyncApply path downgrades to a warning and an
    already-persisted config still boots (#1960); DSCP wins on that boot.

The conflict rejection rests on our own semantics (one TOS byte, two
classifiers, no defined composition), NOT on a claim about Junos
behaviour, which I have no way to verify here. If someone can check a
real vSRX and Junos defines a precedence order, that should override
this choice.

Golden 4406 regenerated. Verified the diff is EXCLUSIVELY the new struct
field before accepting it: 18 keys added, 0 removed, 0 changed, every
added key `INetPrecedenceClassifierDefs`. A golden regen that masks a
real behavior change is its own defect, so this was checked rather than
assumed.

Remaining before this is mergeable:

  1. Wire: `cos_inet_precedence_classifier` on the interface snapshot and
     an `inet_precedence_classifiers` list (protocol/snapshot.rs,
     protocol/cos.rs) plus the Go publish side.
  2. Rust: an 8-entry precedence_queue_by_prec table beside
     dscp_queue_by_dscp (forwarding_build/cos.rs) and a
     resolve_cos_inet_precedence_classifier_queue_id arm reading
     (dscp >> 3) & 0x7.
  3. Retract the classifier half of the accepted-but-inert advisory
     (compiler_validate_warn.go) once the arm lands. The rewrite-rules
     inet-precedence advisory STAYS -- that direction remains inert.
  4. A RUNTIME assertion per the #6850 doctrine: a packet whose
     precedence bits map to a forwarding class must land in that class's
     queue. A compile-and-store test would pass identically with no
     dataplane consumer, which is the state this knob is in today.
  5. Conflict-gate tests, including the lenient-path downgrade.

Advances #6847.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
The `inet-precedence` behavior-aggregate classifier was accepted at
commit and did nothing. #4316 recorded only the classifier NAMES, and
the unit-level `classifiers` schema node had no `inet-precedence` child
at all -- so the classifier was definable at the top level and NOT
bindable, and `set class-of-service interfaces <if> unit <n> classifiers
inet-precedence <name>` was rejected outright by the schema. An imported
vSRX config failed at the bind line rather than silently misclassifying.

The preceding WIP commit added the Go config layer, including the unit
binding site. That left the tree in a state strictly WORSE than before:
an operator could bind the classifier and it silently did nothing, which
converts a loud failure into a quiet one. This commit lands the
enforcement behind it, so the branch is coherent as a whole.

Wire, additive on both sides. `inet_precedence_classifiers` on the CoS
snapshot and `cos_inet_precedence_classifier` on the interface snapshot.
An older helper ignores both keys; a newer helper reading an older
snapshot gets the empty default, which is "no inet-precedence binding" --
the pre-change behaviour exactly. `omitempty` keeps the wire
byte-identical for every config that does not use one.

Dataplane. An 8-entry `inet_precedence_queue_by_prec` table is built
beside `dscp_queue_by_dscp`, and
`resolve_cos_inet_precedence_classifier_queue_id` reads
`(dscp >> 3) & 0x7`. That shift is load-bearing and was verified rather
than assumed: `frame/generated.rs:78` parses `packet[1] >> 2`, so
`meta.dscp` is already the 6-bit DS field and IP precedence is its top 3
bits. The arm is family-agnostic exactly like the existing dscp arm --
this codebase has never split the DS-field classifier by address family,
and splitting it for inet-precedence alone would be a new inconsistency.

Ordering. The arm is spliced in after DSCP and before 802.1p at all five
call sites, so both L3 arms precede the L2 one. DSCP-before-inet-
precedence is not incidental: a unit binding both is rejected at commit,
but the tolerant Load / SyncApply path downgrades that to a warning so an
already-persisted or peer-synced config still boots (#1960), and this
order is what makes the warning's claim -- "DSCP wins on that boot" --
true.

Three gaps in the Go half that would have left the feature dead even
with the classify arm present. Each was found by a test, not by reading:

  1. `coSInterfaceUnitHasBinding` did not list `INetPrecedenceClassifier`,
     so a unit binding ONLY that classifier was parsed and then
     DISCARDED, taking the whole CoS interface with it. Nothing reached
     the snapshot.
  2. The #1183 `useful_cos_state` gate had no inet-precedence arm, so an
     interface with no shaping-rate and no scheduler-map was skipped even
     once the binding survived -- i.e. exactly the plain `unit 0
     classifiers inet-precedence <name>` config this work is about.
  3. The `ba_reclassify` gate had no inet-precedence arm, so a flow's
     queue would freeze on its seed packet's marking. Classifying once
     and then ignoring every later marking change is the same wrong-queue
     outcome as not classifying, with a harder-to-see cause.

The entry's `loss-priority` is wired into `resolve_cos_loss_priority`
alongside the queue arm. Without it `loss-priority high` would compile,
cross the wire, and then silently apply the LOW egress rewrite -- an
accepted-but-inert sub-knob inside the fix for accepted-but-inert.

The classifier half of the #4316 advisory is retracted; keeping an
"inert" warning on a working classifier would be actively wrong. The
`rewrite-rules inet-precedence` advisory STAYS -- that direction is still
inert. The advisory test asserts the retracted string is ABSENT rather
than merely dropping it from the expected list, so reinstating it fails.

Validation. Fourteen guards were mutation-proved: each reverted
individually, observed RED with an ASSERTION rather than a build break,
then restored from a byte snapshot and the restore verified. Two harness
errors of my own surfaced that way and were fixed -- a build-break marker
that matched cargo's own `error: test failed` line and reported seven
false negatives, and one test that failed via a nil-pointer panic instead
of an assertion (now it steps through the lookup and names what broke).

MEASURED negative result, recorded in the test comment rather than
papered over: the out-of-range fail-closed check exists at two sites
whose key sets are always identical, because `queue_by_prec` and
`lp_by_prec` are filled from the same entries in the same loop. No
snapshot can reach one without the other, so mutating either alone leaves
the test GREEN -- the property binds only when both are mutated. The
comment states that scope instead of claiming each site is independently
load-bearing. Both are kept because each is the natural bounds check for
its own table.

Suites: full `go test ./...` exit 0; `go test ./pkg/refactoraudit/` exit
0. The Rust `--bin` suite is 4241 passed / 1 failed against 4233 passed /
1 failed at the branch base `ad9591177` measured in a detached worktree
-- the same single test
(`current_generation_install_and_delete_still_apply_on_poisoned_shared_mutex`,
a known process-global HA counter flake whose fix is not yet merged), and
the +8 delta is exactly the eight new Rust tests. `protocol_wire_v1.json`
regenerated; the diff is exactly three additive keys, 0 removed, 0
changed. No Rust formatter was run; gofmt was applied only to the three
Go files edited.

Advances #6847.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
Paul Saab added 4 commits August 6, 2026 00:28
Resolve the sole conflict, _Log.md, by union: every entry from both
sides is retained and none is rewritten. The file is no longer
append-ordered, so line counts and prefix checks say nothing useful
about the result; the resolution was verified structurally instead, by
confirming that each pre-merge side diffs into the merged file with
add-hunks only and no changed-or-deleted hunk on either side.

Every other path merged without conflict. Because a clean textual
auto-merge can still break compilation when a signature moves on one
side, that was confirmed by building rather than by inspection: go build
./... clean on the merged tree, and cargo check --all-targets clean
for the Rust crate.

Advances #6877.
# Conflicts:
#	_Log.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant