Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
a036a6f
build(nix): keep debug outputs lean
daniel-noland Aug 15, 2026
b1ef45d
feat(debug): add a version-matched core viewer
daniel-noland Aug 15, 2026
74af5d2
feat(debug): add a live DAP debugger
daniel-noland Aug 15, 2026
43ad401
feat(debug): add a syscall tracer image
daniel-noland Aug 15, 2026
9b9b9bf
ci: publish debug images on a deliberate cadence
daniel-noland Aug 15, 2026
c666922
test(debug): exercise the debug images instead of only building them
daniel-noland Aug 16, 2026
06c0cca
feat(debug): run a binary or a single test inside the debug images
daniel-noland Aug 16, 2026
1f46121
docs: Record what a commit message carries
daniel-noland Aug 26, 2026
d2e63fe
build(just): Write an lcov report beside the html one
daniel-noland Aug 26, 2026
c99490e
build(nix): Add cargo-expand, cargo-mutants and cargo-show-asm to the…
daniel-noland Aug 26, 2026
d1398e3
feat(debug): Build gdb and perf to run where there is no nix store
daniel-noland Aug 26, 2026
9016291
feat(debug): Dump a core from a running process without ending it
daniel-noland Aug 26, 2026
3a27f88
build(nix): Add duvet to the dev toolchain
daniel-noland Aug 19, 2026
90a2468
build(nix): Add deno to the dev toolchain
daniel-noland Aug 20, 2026
1888d67
feat(spec): Cross-check a test citation against its implementation ci…
daniel-noland Aug 20, 2026
fa4dca5
build(just): Recipe-ise duvet, cargo-mutants, and the interlock
daniel-noland Aug 20, 2026
931636e
feat(spec): Let the interlock classify a mutant instead of only killi…
daniel-noland Aug 21, 2026
8b75611
feat(spec): Say why a mutant survived, using coverage
daniel-noland Aug 21, 2026
0ea0d48
fix(spec): Accept the second-half boundary the block guard makes unre…
daniel-noland Aug 21, 2026
5be2ae5
fix(net): Store the checksum an incremental update computes
daniel-noland Aug 7, 2026
88c3696
test(net): Check the HeadersView unsafe boundary against the safe mat…
daniel-noland Aug 8, 2026
6edcf93
docs(net): Correct where the HeadersView soundness check actually fires
daniel-noland Aug 8, 2026
83f48b5
test(net): Check the mutable half of the HeadersView boundary, and ma…
daniel-noland Aug 8, 2026
1ef005f
test(net): Extend the HeadersView differential to every arity the ora…
daniel-noland Aug 8, 2026
bcaf281
test(net): Reach the quoted packet inside an ICMP error, and the exte…
daniel-noland Aug 8, 2026
1f0592e
test(net): Cover the optional matchers, and find what their combinato…
daniel-noland Aug 9, 2026
c7cde9b
test(net): Make the shape follow the chain, and reach every arity's look
daniel-noland Aug 9, 2026
9833e03
test(net): Stack the extension headers the builder always could
daniel-noland Aug 9, 2026
214a62b
test(net): Specialize the ICMP subtypes, and find that conform is dead
daniel-noland Aug 9, 2026
f5188c3
test(net): Keep the embedded vacuity guard armed under coverage
daniel-noland Aug 17, 2026
d76f633
test(routing): Property-test the rib-to-fib conversion
daniel-noland Aug 7, 2026
651e14f
fix(routing): Survive a resolution loop in the next-hop walks
daniel-noland Aug 7, 2026
f73e5e2
test(routing): Model-check a fib against its change log
daniel-noland Aug 7, 2026
f5914fa
fix(routing): Drop a fib's vni alias when the fib goes
daniel-noland Aug 7, 2026
975ce59
fix(routing): Refuse to remove the default vrf
daniel-noland Aug 7, 2026
55dae08
fix(frrmi): Frame a message by what arrived, not by what was asked for
daniel-noland Aug 7, 2026
5930a7a
fix(routing): Install a route with no next-hops as a drop, in the rib
daniel-noland Aug 7, 2026
cc439c9
test(routing): Cover the vrf deletion check
daniel-noland Aug 7, 2026
13d1391
test(routing): Property-test resolution across vrfs
daniel-noland Aug 7, 2026
cb73a72
fix(routing): Keep the interface name out of the next-hop key
daniel-noland Aug 7, 2026
4a29b7c
test(routing): Property-test the control-plane operations
daniel-noland Aug 7, 2026
bde423f
test(routing): Property-test the FRR config renderers
daniel-noland Aug 7, 2026
e808861
test(routing): Model-check the interface table and its attachments
daniel-noland Aug 7, 2026
20dc819
test(routing): Property-test the adjacency table's publish discipline
daniel-noland Aug 7, 2026
9c58af9
fix(flow-entry): Add an insertion that will not displace a live flow
daniel-noland Aug 7, 2026
bddea17
feat(config): Generate port-forwarding exposes for property tests
daniel-noland Aug 7, 2026
26e386f
feat(config): Generate masquerade exposes, and share the overlay arou…
daniel-noland Aug 7, 2026
2c5390f
feat(config): Generate static NAT exposes, and pin the mapping is a b…
daniel-noland Aug 7, 2026
c37054f
fix(config): Refuse a port-forwarding expose the dataplane cannot build
daniel-noland Aug 7, 2026
fca2514
refactor(config): Report a port-forwarding mismatch as a typed error
daniel-noland Aug 7, 2026
4b30d51
test(mgmt): Property-test the configuration chain, and unblock its ge…
daniel-noland Aug 7, 2026
da7879d
test(mgmt): Drive the config builder with generated NAT peerings
daniel-noland Aug 7, 2026
a81bcb8
test(k8s-intf): Generate configurations that are valid by construction
daniel-noland Aug 7, 2026
3fd3f38
test(k8s-intf): Generate peering ACLs
daniel-noland Aug 7, 2026
c0ca439
test(mgmt): Build every dataplane table a validated config implies
daniel-noland Aug 7, 2026
d2aedf8
test(config): Hunt validator permissiveness with near-miss configurat…
daniel-noland Aug 8, 2026
8e511d9
test(mgmt): Let a fuzzing engine drive the near-miss property
daniel-noland Aug 8, 2026
3fa5286
test(k8s-intf): Draw prefixes from slots so exposes cannot overlap
daniel-noland Aug 8, 2026
868052f
fix(k8s-intf): Give every vpc its own slots, and assert the control v…
daniel-noland Aug 8, 2026
aebfa90
test(mgmt): Check that a validated configuration has only one meaning
daniel-noland Aug 8, 2026
92ec8f2
test(config): Assert the validator refuses what a mutation certainly …
daniel-noland Aug 8, 2026
a6ae1c0
test(nat): Catch a static NAT table that holds one of two rules asked…
daniel-noland Aug 8, 2026
4a0d639
fix(k8s-intf): Put the generated gateway in its own gateway groups
daniel-noland Aug 8, 2026
5fa4347
test(mgmt): Check that every expose leaves a trace
daniel-noland Aug 8, 2026
641ac94
test(mgmt): Follow the genid out of MasqueradeConfig
daniel-noland Aug 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ Production artifacts are produced via nix builds in a separate CI workflow.
- `ci:+miri` - Run Miri checks
- `ci:+wasm` - Run the WASM build check
- `ci:+concurrency` - Run Shuttle and Loom tests
- `ci:+debug-images` - Also build and push the core viewer, DAP debugger, and
syscall tracer images. They are built on main, in the merge queue, and on
dispatch regardless, and `ci:+merge-ready` turns them on too, since
`ci-gate` treats that label as enabling every gate; this is for when the
build itself needs debugging
- `ci:+cross` - Build all cross-platform containers
- `ci:+cross/full` - Also run the workspace test suite under qemu-user, on the
two aarch64 musl legs. Gated like every other job, so the merge queue and
Expand Down Expand Up @@ -104,13 +109,92 @@ If those queue failures stop being rare, the phasing is worth revisiting.
- Coverage: `debug` by default; `fuzz` on deep runs
- Miri: required on deep runs; opt-in on pull requests with `ci:+miri`
- Containers: debug/release for dataplane and FRR; release for validator
- Debug images (core viewer, DAP debugger, syscall tracer): deep runs only,
or on a pull request with `ci:+debug-images`
- VLAB configurations: spine-leaf fabric mode, L2VNI/L3VNI VPC modes,
with gateway enabled

### Artifacts

- Container images pushed to GitHub Container Registry (GHCR)
- Release containers published on tag pushes via `just push`
- `ghcr.io/githedgehog/dataplane/core-viewer` opens a core file from the lab.
It carries gdb plus the unstripped binaries and sources for the matching
`ghcr.io/githedgehog/dataplane` build.
Pull the tag matching the build the core came from; symbols only line up with
the exact version and profile that produced it.
The entrypoint takes the core as its only argument:

```console
docker run --rm -it -v /path/to/cores:/cores \
ghcr.io/githedgehog/dataplane/core-viewer:TAG /cores/core.1234
```

- `ghcr.io/githedgehog/dataplane/dev-debugger` debugs a live dataplane from an
editor. It carries bugstalker, which understands Rust's std collections and
enum layouts, and listens for a Debug Adapter Protocol client on port 4711.
Publish the port and point the editor's DAP client at it:

```console
docker run --rm -p 127.0.0.1:4711:4711 ghcr.io/githedgehog/dataplane/dev-debugger:TAG
```

Connecting does not by itself start anything. In remote-DAP mode bugstalker
waits for the client's `launch` request to name the program, so the editor
has to send `program`, and any dataplane arguments as `args`. A request
without `program` is rejected with `launch: missing arguments.program`.
For VS Code, in `.vscode/launch.json`. `type` has to match whatever debug
type the BugStalker extension you installed registers -- it is not a name we
choose, and it differs between extensions, so check the one you have rather
than copying this field blind:

```json
{
"type": "bs",
"request": "launch",
"name": "dataplane (container)",
"debugServer": 4711,
"program": "/bin/dataplane",
"args": []
}
```

For `nvim-dap`, where the first line names the adapter itself, so `type = "bs"`
below is our own label rather than an extension's:

```lua
dap.adapters.bs = { type = "server", host = "127.0.0.1", port = 4711 }
dap.configurations.rust = {
{
type = "bs",
request = "launch",
name = "dataplane (container)",
program = "/bin/dataplane",
args = {},
},
}
```

- `ghcr.io/githedgehog/dataplane/syscall-tracer` records what the dataplane
asks the kernel for, as JSON, using lurk.
It carries the same stripped binaries the release image ships, since nothing
here symbolizes, so it is smaller than the other two -- though not by as much
as that suggests: like them it ships the source tree, which the entrypoint
makes the working directory. Only the debug symbols and the debuggers
themselves are absent.

```console
docker run --rm ghcr.io/githedgehog/dataplane/syscall-tracer:TAG > trace.jsonl
```

The stream is one JSON object per line, except that tracing child threads
makes lurk announce each one with a bare `Attaching to child <pid>` line.
Filter those out if the consumer needs strict JSONL:

```console
jq -R 'fromjson? // empty' < trace.jsonl
```

- Coverage reports from each `coverage/<profile>` job, kept for 7 days:
- `coverage-html-<profile>.tar.gz` - `llvm-cov` HTML report, including the
per-branch counts that Codecov does not render. Unpack and open
Expand All @@ -121,6 +205,53 @@ If those queue failures stop being rare, the phasing is worth revisiting.
Both upload unarchived, so they download as the named file rather than
wrapped in a zip.

### Debugging locally

The published images debug what CI built. To debug what you are building, `just
debug` builds the matching image and runs a workspace binary or a single test
inside it, at whatever `profile`, `platform`, `instrument`, and `sanitize` you
pass. Symbols only line up when those match the build the problem appeared in,
which is the whole reason to go through the image rather than a system gdb.

```console
just debug # pick from a list
just debug bugstalker # pick, then wait for an editor
just debug lurk dataplane # trace syscalls, streams JSON, runs to exit
just debug gdb dataplane # gdbserver on 2345, waits for a client
just profile=fuzz debug gdb test_parse_interface args
just debug-list # print the same list without running anything
```

Name nothing and everything is offered through `skim`, which the dev shell
provides. Name a filter matching one test and it runs without asking; name one
matching several and those are offered. A filter matching nothing is an error
rather than a guess, and so is an ambiguous one when there is no terminal to
ask at, which is what makes this safe to call from a script.

The third argument narrows which archive is searched, so
`just debug gdb some_test args` builds only `args`' tests. It is worth passing:
the default builds every test in the workspace, which is a long wait if all you
wanted was to pick from a short list.

`gdb` and `bugstalker` block until you disconnect and interrupt them; that is
the point. `gdb` prints the `target remote` line to use. `bugstalker` prints a
`.zed/debug.json` entry ready to paste, because in remote-DAP mode it takes the
program from the client's launch request rather than from its own command line,
so connecting an editor is only half of it. The `tcp_connection` field in that
entry is what stops the editor spawning a second debugger of its own.

A test runs with its package directory as the working directory, the way
nextest runs it, so relative paths behave the same as under `just test`.

To open a core file:

```console
just inspect-core /path/to/core.1234
```

Pass the same build settings that produced the binary that dumped
(`just profile=release inspect-core ...`), for the same reason.

---

## Linting and Validation Workflows for Pull Requests
Expand Down
27 changes: 23 additions & 4 deletions .github/workflows/dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ jobs:
outputs:
container_profiles: "${{ steps.container-profiles.outputs.value }}"
parallel: "${{ steps.parallel.outputs.value }}"
container_targets: "${{ steps.container-targets.outputs.value }}"
profiles: "${{ steps.profiles.outputs.value }}"
concurrency: "${{ steps.concurrency.outputs.value }}"
cross: "${{ steps.cross.outputs.value }}"
Expand Down Expand Up @@ -132,6 +133,16 @@ jobs:
on-value: '["debug", "release", "fuzz"]'
off-value: '["debug"]'

# Debug images are opt-in on pull requests because of their size.
- id: "container-targets"
uses: *gate
with:
labels: "debug-images"
# Keep this on one line: `ci-gate` writes the value to GITHUB_OUTPUT
# with a plain printf, which a multi-line value would corrupt.
on-value: '["frr.dataplane", "dataplane", "dataplane-core-viewer", "dataplane-dev-debugger", "dataplane-syscall-tracer", "validator"]'
off-value: '["frr.dataplane", "dataplane", "validator"]'

# Lab jobs require release images but not other release/fuzz checks.
- id: "container-profiles"
uses: *gate
Expand Down Expand Up @@ -425,10 +436,7 @@ jobs:
fail-fast: false
max-parallel: ${{ fromJSON(needs.plan.outputs.parallel) }}
matrix:
nix-target:
- frr.dataplane
- dataplane
- validator
nix-target: "${{ fromJSON(needs.plan.outputs.container_targets) }}"
# TODO: enable cfi and safe-stack on release when possible
profile: "${{ fromJSON(needs.plan.outputs.container_profiles) }}"
exclude:
Expand All @@ -443,6 +451,17 @@ jobs:
with:
recipe: "ci::push-container"
recipe_args: "${{ matrix.nix-target }} ${{ matrix.profile }} ${{ needs.version.outputs.version }}"

# A debug image that builds is not a debug image that works. Two of
# these shipped with entrypoints that could not do what the README
# documents, and building them said nothing about it.
- name: "smoke"
if: "${{ startsWith(matrix.nix-target, 'dataplane-') }}"
uses: *just
with:
recipe: "ci::smoke-container"
recipe_args: "${{ matrix.nix-target }} ${{ matrix.profile }}"

- *verify-clean-tree
- *tmate

Expand Down
18 changes: 18 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,21 @@ Follow the [development guide](development/README.md).

If you make a design decision or do a code review, try to cite the section of the
development guide you are following.

## Commit messages

A commit message signposts the diff; it does not restate it. Rationale belongs where it
constrains the code -- in the code, its doc comments, or the development guide -- because a
second copy in the message is a second thing to keep correct, and the two will drift.

A message carries what the diff cannot:

- the "why" of the commit,
- non-obvious process facts,

It does not re-summarize the change, narrate how it was made, or repeat an argument the
commit already puts in the tree. For a docs commit, the file is the content; the message
points at it.

Keep `git log` scannable: a few lines by default, longer only where the diff is opaque and
the message is what makes the commit reviewable.
4 changes: 4 additions & 0 deletions ci.just
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ cross platform libc +args:
cross-test platform libc:
NEXTEST_PROFILE=cross-qemu just {{ _lab }} platform={{ platform }} libc={{ libc }} profile=debug test

# Verify a debug image's entrypoint actually does its job.
smoke-container target profile:
just {{ _lab }} profile={{ profile }} platform=x86-64-v3 smoke-container {{ target }}

# Publish both content-derived and discoverable per-commit tags.
[script]
push-container target profile version:
Expand Down
5 changes: 5 additions & 0 deletions config/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ license.workspace = true
publish.workspace = true
version.workspace = true

[features]
# Generators for the configuration types, for property tests in this crate and downstream.
bolero = ["dep:bolero", "lpm/bolero"]

[dependencies]
# internal
common = { workspace = true }
Expand All @@ -16,6 +20,7 @@ net = { workspace = true }

# external
arc-swap = { workspace = true }
bolero = { workspace = true, optional = true, default-features = false, features = ["alloc"] }
chrono = { workspace = true, features = ["alloc", "std"] }
derive_builder = { workspace = true, features = [] }
ipnet = { workspace = true }
Expand Down
12 changes: 11 additions & 1 deletion config/src/converters/k8s/config/expose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,8 @@ mod test {
"10.0.4.0/24".parse::<Prefix>().unwrap(),
),
]);
let expose_gen = k8s_intf::bolero::expose::LegalValueExposeGenerator::new(&subnets);
// One expose at a time here, so any slot will do.
let expose_gen = k8s_intf::bolero::expose::AnyExposeGenerator::new(0, &subnets);
bolero::check!()
.with_generator(expose_gen)
.for_each(|k8s_expose| {
Expand Down Expand Up @@ -549,6 +550,7 @@ mod test {
})
.unwrap_or(vec![]);
k8s_nots.sort();
k8s_nots.dedup();
let k8s_subnets = k8s_expose
.ips
.as_ref()
Expand All @@ -565,7 +567,13 @@ mod test {
})
.unwrap_or(vec![]);
k8s_ips.extend(k8s_subnets);
// Sorted *and* deduplicated, because the conversion collects into a set-like
// structure: a prefix written twice in one expose means the same as writing it once,
// and comes out once. This only came up when the generators started producing
// repeats -- the previous ones drew each prefix from a uniqueness-preserving
// generator, so the question never arose.
k8s_ips.sort();
k8s_ips.dedup();

let k8s_as = k8s_expose.r#as.as_ref().map(|r#as| {
let mut ret = r#as
Expand All @@ -574,6 +582,7 @@ mod test {
.map(|r#as| r#as.cidr.as_ref().unwrap().clone())
.collect::<Vec<_>>();
ret.sort();
ret.dedup();
ret
});

Expand All @@ -584,6 +593,7 @@ mod test {
.map(|r#as| r#as.not.as_ref().unwrap().clone())
.collect::<Vec<_>>();
ret.sort();
ret.dedup();
ret
});

Expand Down
14 changes: 12 additions & 2 deletions config/src/converters/k8s/config/peering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,20 @@ mod test {
use k8s_intf::bolero::peering::{
LegalValuePeeringsGenerator, LegalValuePeeringsPeeringGenerator,
};
use k8s_intf::bolero::{AddressFamily, NatFlavour};
use lpm::prefix::Prefix;

use crate::converters::k8s::config::{SubnetMap, VpcSubnetMap};

#[test]
fn test_vpc_manifest_conversion() {
let subnets = SubnetMap::new(); // Let this be empty since we are test subnet conversion elsewhere
let generator = LegalValuePeeringsPeeringGenerator::new(&subnets);
// any flavour, one family, a couple of exposes: this is testing the conversion, not the
// rules the generators satisfy
let flavours = NatFlavour::all();
let generator =
// One manifest at a time here, so vpc zero: there is nothing to keep it disjoint from.
LegalValuePeeringsPeeringGenerator::new(&subnets, &flavours, AddressFamily::V4, 3, 0);
bolero::check!()
.with_generator(generator)
.for_each(|peering| {
Expand Down Expand Up @@ -169,7 +175,11 @@ mod test {
]),
),
]);
let generator = LegalValuePeeringsGenerator::new(&subnets).unwrap();
let flavours = NatFlavour::all();
let families = AddressFamily::all();
let groups = vec!["gwgroup-0".to_string()];
let generator =
LegalValuePeeringsGenerator::new(&subnets, &flavours, &families, 3, &groups).unwrap();
bolero::check!()
.with_generator(generator)
.for_each(|peering| {
Expand Down
27 changes: 26 additions & 1 deletion config/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,33 @@ pub enum ConfigError {
#[error("Invalid ACL configuration: {0}")]
InvalidAcl(String),
// NAT-specific
#[error("Mismatched prefixes sizes for static NAT: {0:?} and {1:?}")]
/// The two sides of a static NAT expose cover different numbers of address-port pairs.
//
// The sizes are rendered with `Debug` because `PrefixWithPortsSize` is a 145-bit bnum type
// with no `Display`, and Debug pads it out to a run of digits that reads as gibberish. Hence
// leading with what to change and leaving the numbers to the end.
#[error(
"Mismatched sizes for static NAT: the exposed prefixes and the range they translate to \
must cover the same number of address-port pairs (they cover {0:?} and {1:?})"
)]
MismatchedPrefixSizes(PrefixWithPortsSize, PrefixWithPortsSize),
/// The two sides of a port-forwarding expose have prefixes of different lengths.
///
/// Distinct from [`ConfigError::MismatchedPrefixSizes`], which compares addresses times ports.
/// That product can match while the lengths do not -- a `/32` carrying 100 ports and a `/30`
/// carrying 25 both come to 100 -- so reporting it as a size mismatch would name two numbers
/// that are equal. A port-forwarding rule maps addresses one for one, so it is the lengths
/// that have to agree.
#[error(
"Mismatched prefix lengths for port forwarding: /{private} exposed and /{public} \
translated to; a rule maps addresses one for one, so the two must be the same length"
)]
MismatchedPrefixLengths { private: u8, public: u8 },
#[error(
"Mismatched port range sizes for port forwarding: {private} ports exposed and {public} \
translated to; a rule maps ports one for one, so the two must be the same size"
)]
MismatchedPortRangeSizes { private: usize, public: usize },
#[error("Peering {0} has manifests using incompatible NAT modes")]
IncompatibleNatModes(String),
#[error("Vpc {0} has a peering with no exposes")]
Expand Down
Loading
Loading