Getting started

Declarative fleet

One fleet.yaml describes every appliance — groups, patches, per-node facts — and `sihactl gen config` renders one ApplianceConfig document per appliance, ready to apply with client-side prune.

One fleet.yaml is the source of truth for a whole fleet. sihactl gen config reads it and writes one ApplianceConfig document per appliance under configs/. config apply on a document interprets it into COSI resources on the node, with automatic client-side pruning — what you remove from the file disappears from the node.

Source vs generated

fleet.yaml              # SOURCE — you write and commit this
patches/hardening.yaml  # reusable patch, @-included where needed
patches/edge-acl.yaml   # another patch

gen/dev/configs/        # GENERATED by `gen config` (gitignored, never committed)
  fw-1.yaml             #   fw-1's ApplianceConfig document
  fw-2.yaml             #   fw-2's ApplianceConfig document
  sihaconfig            #   your day-2 client identity

You edit fleet.yaml (+ patches); gen config writes everything under configs/. That directory is gitignored (it may contain sensitive keys). Your secret (the fleet CA) stays outside, SOPS-encrypted (see Getting started).

fleet.yaml

version: v1alpha1
fleetName: prod

patches:                            # 1. global — every appliance
  - "@./patches/hardening.yaml"

groups:
  - name: edge
    patches: ["@./patches/edge-acl.yaml"]

appliances:
  - name: fw-1
    endpoint: "192.0.2.10:6443"
    groups: [edge]
    network:
      interfaces:
        - name: wan
          pci: "0000:05:00.0"
          addresses: ["203.0.113.2/24"]
          rxMode: adaptive
        - name: lan
          pci: "0000:06:00.0"
          addresses: ["10.0.0.1/24"]
    nat:
      interfaces:
        - name: lan-inside
          interface: lan
          role: inside
        - name: wan-outside
          interface: wan
          role: outside

  - name: fw-2
    endpoint: "192.0.2.11:6443"
    groups: [edge]
    network:
      interfaces:
        - name: wan
          pci: "0000:05:00.0"
          addresses: ["203.0.113.3/24"]
        - name: lan
          pci: "0000:06:00.0"
          addresses: ["10.0.0.1/24"]
    nat:
      interfaces:
        - name: lan-inside
          interface: lan
          role: inside
        - name: wan-outside
          interface: wan
          role: outside
  • fleetName is informational.
  • Global patches reach every appliance.
  • groups are named sets with their own patches. An appliance opts in by listing group names. fw-1 and fw-2 both get the edge patch; a third appliance that omits groups: [edge] is untouched.
  • Structured sections (network:, nat:, …) state what this appliance IS — its ports and addresses. These are its facts; they beat global/group patches (more-specific-wins).
  • Per-appliance patches (not shown) are last-mile exceptions that beat everything.

Cascade order

global patches

group patches (in the appliance's groups list order)

structured base (the appliance's own sections — beats patches)

appliance patches (last-mile — beats everything)

Each step is a schema-aware strategic merge: lists merge by their key (e.g. by name for interfaces), scalars replace, $patch: delete removes a keyed entry, @./file.yaml inlines a file. Unknown fields are hard errors.

The rule: structured fields state what an appliance IS (its facts: ports, addresses, peers). Patches state shared policy (global/group) or last-mile exceptions.

ApplianceConfig — the document

Each gen config run writes one ApplianceConfig document per appliance. It is a human-oriented YAML document — a designed schema with concrete section names. COSI types never appear in it.

Sections:

SectionWhat it configures
machine.dataplaneVPP performance tier, hugepages, plugins
machine.managementDay-2 management surface scoping (which physical NICs stay management-plane)
network.interfacesAll VPP-owned interfaces (PMD, bond, bridge, tap, loopback, VXLAN) + sub-interfaces (vlans, qinq)
routing.vrfsIP routing tables
routing.staticNamed static routes
routing.bgpBGP per-VRF configuration + peers
firewall.aclsACLs with ordered rules (by seq:) and bindings (bindTo:) folded in
natInterface roles, address pools, static + identity mappings
loadBalancerlb plugin (VIPs, NAT L4 services, interface modes)
vrrp.virtualRoutersVRRP virtual routers
dns.forwardersDNS forwarding / caching
dhcp.servers / .relaysEmbedded DHCP server or relay

Every operator-writable COSI type is reachable from the schema — enforced by the registry-driven coverage test.

Interface kinds are identified by which discriminator key is present:

KeyKind
pci: "0000:05:00.0"pmdDPDK PMD NIC bound by PCI address
hostDevice: enp5s0host-interfaceAF_PACKET debug attach
bond: {members, mode}bondLink aggregation
bridge: {members, bvi}bridgeL2 bridge-domain
tap: {hostDevice, ...}tapVPP host tap
loopback: {}loopbackSoftware loopback
vxlan: {vni, local, remote, l3Peer}vxlanL3VNI tunnel

VLANs and QinQ sub-interfaces nest INSIDE their parent entry:

- name: uplink
  bond:
    members: [upA, upB]
    mode: xor
  addresses: ["10.99.0.2/24"]
  vlans:
    - id: 100
      addresses: ["10.100.0.2/24"]
  qinq:
    - outer: 200
      inner: 100
      addresses: ["10.200.0.2/24"]

This derives three Interfaces resources: uplink (bond), uplink.100 (VLAN 100), uplink.200.100 (QinQ).

ACL rules use seq: as both the merge key and the sort key; bindTo: is folded in:

firewall:
  acls:
    - name: edge-in
      family: IPv4
      defaultAction: DROP
      rules:
        - seq: 10
          action: ACCEPT
          protocol: 1
          description: "ICMP diagnostics"
        - seq: 20
          action: ACCEPT_STATEFUL
          protocol: 6
          destinationPortFirst: 443
      bindTo:
        - interface: wan
          direction: INPUT

Patches — layered overrides

A patch is a fragment of an ApplianceConfig document (no version: field). It merges by the same keys as the document. Examples:

Add a binding to an ACL without touching its rules (shows the recursive keyed-list merge):

firewall:
  acls:
    - name: edge-in
      bindTo:
        - { interface: dmz, direction: INPUT }

This adds the dmz binding; the wan binding and all rules are kept.

Remove one rule by seq:

firewall:
  acls:
    - name: edge-in
      rules:
        - seq: 20
          $patch: delete

Remove an appliance from a group’s ACL (per-appliance patch):

firewall:
  acls:
    - name: edge-in
      $patch: delete

Generate, validate, apply

# render fleet.yaml → configs/*.yaml + sihaconfig
sihactl gen config -f fleet.yaml --secret siha-secret.yaml

# inspect what would land on fw-1 (derived COSI types+ids, no apply)
sihactl gen config -f fleet.yaml --secret siha-secret.yaml --show-derived fw-1

# validate without applying (runs interpretation + adapter validation)
sihactl config validate -f configs/fw-1.yaml

# adopt the node (idempotent — safe to re-run)
sihactl adopt --secret siha-secret.yaml --addr 192.0.2.10:6443

# then apply (or re-apply) day-2 config
sihactl config apply --sihaconfig configs/sihaconfig --addr 192.0.2.10:6443 \
  -f configs/fw-1.yaml

# one-shot ZTP alternative: adopt + apply in one command
sihactl config apply --insecure --secret siha-secret.yaml \
  --sihaconfig configs/sihaconfig --addr 192.0.2.10:6443 \
  -f configs/fw-1.yaml

Key points:

  • gen config never touches a node — read and render only; safe to re-run (idempotent).
  • config apply is declarative: what is in the document is the desired state. Resources of covered types that no longer appear in the document are deleted from the node (client-side prune).
  • Wipe guard: --allow-wipe is required if the document derives zero resources (empty document against a configured node). --dry-run previews creates/updates/deletes without touching the node.

See CLI · sihactl for the full command surface.