The core of Hedgehog Open Network Fabric: the Kubernetes API (CRDs) that models a fabric, the controller that turns that model into per-switch configuration, and the agent that runs on each switch and applies it.
This repo does not install anything by itself. A fabric is deployed by
fabricator (hhfab), which builds the
control node, installs these CRDs and the controller, and runs the VLAB.
User-facing docs live at docs.githedgehog.com.
docs/api.md (generated) is the API reference; pkg/agent/dozer/bcm/README.plan.md
documents what config we actually push to a Broadcom SONiC switch and why — read that
before changing the planner.
Prerequisites: Go 1.26+ (see go.mod), just 1.36+, Docker.
just build # generate, then build all binaries into bin/ (linux/amd64)
just test # run tests
just lint # golangci-lint + license headers + actionlint
just --list # everything elseAlways go through just — raw go build/go test miss the build tags, ldflags and
codegen steps and will produce misleading results.
just gen (a dependency of both build and test) ends with test-docs, which
builds the API reference in a container, so Docker is required to run the tests.
These packages are imported by fabricator, gateway and other Hedgehog repos, so treat
them as public API: a change here ripples outward. Run just gen after editing any type
— it regenerates deepcopy funcs, CRDs, RBAC and docs/api.md.
| Group | Contents |
|---|---|
wiring/v1beta1 |
Physical layer: Switch, Server, Connection, SwitchGroup, SwitchProfile, ServerProfile, VLANNamespace |
vpc/v1beta1 |
Overlay: VPC, VPCAttachment, VPCPeering, External, ExternalAttachment, ExternalPeering, IPv4Namespace |
agent/v1beta1 |
Agent (the per-switch desired state, written by the controller, read by the agent) and Catalog (allocated IDs: VNIs, ASNs, per-switch identifiers) |
gateway/v1alpha1 |
Gateway-side API: Gateway, GatewayGroup, GatewayPeering, VPCInfo |
gwint/v1alpha1 |
GatewayAgent — the gateway's equivalent of Agent |
dhcp/v1beta1 |
DHCPSubnet, consumed by fabric-dhcpd |
meta |
Shared types and constants, including FabricConfig (the knobs the controller and agent both read) |
valid |
Peering validation, injected into FabricConfig so webhooks and hhfctl share one implementation |
| Binary | Role |
|---|---|
fabric (cmd/) |
The controller: reconcilers + admission webhooks, runs on the control node |
agent |
Runs on every switch as a systemd unit (as root), applies its own Agent CR |
hhfctl |
Operator CLI for creating/inspecting VPCs, connections, externals, switches |
fabric-boot |
ONIE/ZTP boot server for switch provisioning |
fabric-dhcpd |
DHCP server for VPC subnets, driven by DHCPSubnet |
fabric-nos-install |
NOS installer, embedded into fabric-boot |
fabric-gen |
Codegen/doc tool (switch profile reference) |
| Path | Role |
|---|---|
ctrl/ |
Reconcilers (*_ctrl.go) and admission webhooks (*_wh.go). agent_ctrl.go is the important one: it collects everything relevant to a switch and writes its Agent CR |
manager/librarian/ |
The "librarian": allocates and persists fabric-wide identifiers (VNIs, ASNs, port channel IDs, external leak IDs) into the Catalog |
agent/ |
The switch agent: watch loop, enforce loop, self-upgrade, metrics (alloy/), switch state collection (switchstate/) |
agent/dozer/ |
The NOS abstraction. dozer.go defines Spec (the whole switch config as data) and the Processor interface |
agent/dozer/bcm/ |
Broadcom SONiC implementation: plan.go builds the desired Spec from an Agent CR, spec_*.go files map each piece of Spec to OpenConfig/gNMI and back, state.go reads switch state |
agent/clsp/, agent/cmls/ |
Celestica SONiC+ and Cumulus Linux processors (work in progress) |
boot/ |
ZTP/ONIE boot server and NOS install logic |
hhfctl/ |
hhfctl command implementations, incl. inspect/ |
client/apiabbr/ |
Compact API representation used by hhfab and hhfctl for bulk create/diff |
util/ |
Small helpers; apiutil holds the shared queries over API objects that both the controller and hhfctl use |
config/ holds kustomize bases and Helm charts, hack/ the tool versions and extra
just recipes, docs/ the generated reference docs.
The controller follows the Kubernetes
operator pattern: the
CRDs in api/ are the desired state, and the
controllers in pkg/ctrl
reconcile until the fabric matches it. The layout (api/, cmd/, config/, PROJECT)
is kubebuilder-scaffolded — see the
kubebuilder book for the conventions.
The switch agent then repeats the same pattern one level down, reconciling its Agent CR
against the switch itself:
- A user (or
hhfab) creates wiring and VPC objects. Webhooks inpkg/ctrl/*_wh.govalidate them at admission time — that's where most invariants are enforced. AgentReconcilergathers everything that concerns one switch, asks the librarian for any identifiers it needs, and writes a singleAgentCR per switch.- The agent on the switch watches its own
AgentCR. On every change, and otherwise every 2 minutes (EnforcePeriod), it runs:PlanDesiredState→LoadActualState→CalculateActions(diff of twoSpecs) →ApplyActions(gNMI writes). - It writes
last-desired.yaml/last-actual.yamlinto its basedir and reports state back intoAgent.Status.
The consequence of step 3 being a diff of round-tripped data: if a field doesn't survive the read-back from the switch, the agent rewrites that object on every pass, every two minutes.
just test runs everything. The most valuable tests are the golden planner tests in
pkg/agent/dozer/bcm/ (plan_test.go): each case is an Agent CR in testdata/, and
the test asserts the planned Spec, the computed actions, and the resulting gNMI
payloads against checked-in expectations.
To add a case: drop testdata/<name>.in.agent.yaml (easiest source is a real
kubectl get agent <switch> -o yaml from a VLAB), add <name> to the table in
plan_test.go with a comment saying what topology it covers, then:
just test-update # regenerate all goldens
just test-update ./pkg/agent/dozer/bcm/... # or just this packagetest-update overwrites *.expected.yaml; review that diff — it is the only review
the planner's output gets. The *.actual.yaml files are written on every run and are
gitignored, so on a failure you can diff them against the expected ones directly.
just test-api installs the API chart onto a kind cluster and waits for the CRDs
(just test-api-auto creates and deletes the cluster around it).
Run the locally built agent against a live switch, taking desired state from the cluster, e.g.:
KUBECONFIG=<vlab-workdir>/vlab/kubeconfig \
just run agent remote -n leaf-01 -k -a <control-node-host> -u admin-k is dry-run (drop it to actually enforce), -a is the ssh jump host used to reach the
switch (e.g. an alias from your ~/.ssh/config — the VLAB's control node).
It writes last-desired.yaml / last-actual.yaml in the repo root, so an empty
diff last-actual.yaml last-desired.yaml is a fast, exact check that a spec round-trips.
This takes seconds; prefer it to push+patch while iterating. Note that individual actions
are only logged during apply, so a dry run just reports a count.
just oci_repo=<REGISTRY> version_extra=-<YOUR_INITIALS> push patchoci_repo is the registry you are targeting - ask us for the one we typically target
for development. version_extra is an optional suffix to tell your builds apart.
push and patch must be in a single just invocation: the version string gets a
random suffix whenever the tree is dirty, so two separate invocations compute different
versions and the patch ends up pointing at an image that was never pushed.
Things that cost us time once already, so they are written down rather than rediscovered.
- Path form is
/<module>/<table>/<list>, never/<module>/<module>/<table>— the ygot Go type names repeat the module and tempt you into the doubled form. - The destination struct must be the parent of the node the path names. Pointing at
.../IF_REASON_EVENTwith dest*..._IF_REASON_EVENTunmarshals zero rows and returns no error./sonic-vrf/VRF/VRF_LISTwith dest*SonicVrf_SonicVrf_VRFis the correct in-repo template. - Both failures above are silent, so verify a new table read by counting rows, not by checking the error.
SetRequestvalues must be rooted under the path's last segment: path.../configtakes{"config": {"enabled": false}}; a bare{"enabled": false}is rejected.ON_CHANGEis a per-leaf allowlist.counters/*and the wholestatesubtree return nothing at all — no error, no initial value, no sync-response. Treat a missing sync-response as a failure, not as "no events yet".
On Broadcom SONiC, sonic-cli is the only supported way to query or configure the box.
Don't use vtysh, and don't use the bash show utilities — they can report state that
has drifted from what Broadcom considers the source of truth.
- It refuses to run as root (
FATAL: root cannot launch CLI). The agent runs as root, so anything shelling out to it needssudo -u admin sonic-cli -c "...". - Under a pty it paginates and hangs at
--more--. Append| no-moreinside the sonic-cli command. (That's sonic-cli syntax — if bash saysno-more: command not found, your command leaked out of sonic-cli.) - Syntax differs from FRR:
show bgp ipv4 unicast summary, notshow ip bgp summary. Interface config takes the NOS name (interface Ethernet6), not the alias (Eth1/7). - Route-maps hand-created through repeated
-cargs never persist (exit 0, nothing created) — submode needs a real pty. If you do hand-create something to probe, delete it afterwards: config the agent cannot parse can wedge it in a crash loop insideLoadActualState, before it gets far enough to upgrade itself out of the problem.
To find which gNMI path an operation uses, run show logging -af | grep -i translib
from the switch's bash console while running the equivalent sonic-cli command in another
window. Stop the agent first (sudo systemctl stop hedgehog-agent) for a clean trace.
Copyright 2023 Hedgehog.
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.