Command Ownership and Plugin Structure
The Folder Test
Every feature in Ze passes two folder tests:
Copy test: copy a plugin folder into the project, run codegen (make generate),
and the plugin's commands, YANG schema, and handlers are live. No manual wiring.
Delete test: delete a plugin folder, run codegen, and every one of its features disappears. Every other plugin and the core keep working. No dangling references, no broken builds.
These two tests are the load-bearing invariant of the architecture. Everything below exists to make them hold.
Three Directories, Three Roles
internal/
core/ # shared primitives, no subsystem knowledge
component/ # shared subsystem implementations and services
plugins/ # self-contained command-only and full-subsystem owners
core/ -- Infrastructure Primitives
Leaf packages that provide reusable services. They depend on nothing else
internal (or only on other core/ packages). No subsystem-specific knowledge.
Examples: slogutil (logging), crashlog (panic capture), env (environment
variables), family (address families), health (health registry), metrics
(prometheus), events (event bus), diagnostic (doctor codes), textbuf
(string building), paths (file locations).
component/ -- Shared Subsystem Implementations
Subsystem logic that multiple owners use can compose core/ and other
component/ packages. A component owns configuration YANG when its data model
belongs to the shared subsystem rather than one removable plugin.
Examples include bgp (BGP engine), config (config system), cli (SSH CLI
editor), iface (interface management), host (hardware detection),
firewall, doctor (readiness checks), web, and l2tp.
plugins/ -- Self-Contained Feature Owners
Each folder under internal/plugins/ owns a removable feature surface. Two
plugin shapes use this directory:
- A command-only plugin owns command YANG, RPC handlers, and CLI
registration. It delegates the operation to a
component/orcore/package. - A full-subsystem plugin also owns its protocol or service runtime, configuration, lifecycle, and state in the same folder.
Both shapes can own user commands. Removing either folder removes all surfaces that belong to that owner.
Plugin Directory Layout
internal/plugins/<name>/
register.go # lifecycle or offline CLI registration, when needed
*.go # runtime, config, and state for a full-subsystem plugin
yang/
ze-<name>-*.yang # hand-written command or config definitions
embed.go # GENERATED: //go:embed vars
register.go # GENERATED: yang.RegisterModule() calls
cmd/
register.go # pluginserver.RegisterRPCs() in init()
handler.go # RPC handler functions
Command-only plugins omit subsystem runtime files. Full-subsystem plugins keep their runtime with the command and configuration surfaces that they own.
YANG as Data, Not Code
YANG files are declarative definitions, not Go code. They live in a yang/
subfolder (not schema/) to make this clear. The Go glue files in yang/
(embed.go, register.go) are generated by codegen from the .yang files
present in the directory. The only hand-written file is the .yang itself.
This means adding a YANG command schema to a plugin is:
- Write
ze-<name>-cmd.yangin the plugin'syang/folder - Run
make generate - Done: the codegen produces
embed.go,register.go, and updatesall.go
How Codegen Enables the Folder Test
The codegen (scripts/codegen/plugin_imports.go) scans the directory tree for:
- Packages containing
yang.RegisterModulecalls -> adds toall.goschema imports - Packages containing
pluginserver.RegisterRPCscalls -> adds toall.goRPC imports yang/directories containing.yangfiles -> generatesembed.go+register.go
Because discovery is directory-based, copying a plugin folder in (or deleting it) and re-running codegen is all that's needed to wire (or unwire) the plugin.
YANG Container Merge
Owners declare their commands by re-stating the path from the verb root:
module ze-host-cmd {
namespace "urn:ze:host:cmd";
prefix hostcmd;
import ze-extensions { prefix ze; }
container show {
container host {
container cpu {
ze:command "ze-show:host-cpu";
}
}
}
}
The YANG loader unions same-named top-level containers across all registered
modules. The owner module needs no import or augment of the central verb
schema. Give each module a unique namespace and prefix.
Verb-Root Anchors
Multi-owner verbs (show, clear, monitor, delete, set, update) keep a
bare anchor in the central package internal/component/cmd/<verb>/. The anchor
declares only truly generic commands (cross-plugin aggregations, process-global
introspection). Each owner container-merges its subtree onto the verb root.
A central self-containment test bans every migrated token to prevent drift back.
What Stays Central
A command stays in the central verb package only when it has no single removable owner:
- Aggregates a cross-plugin registry (
show policy list,show metrics name) - Reads the generic core system (
show version,show health,show uptime) - Is process-global (
show system memory,show system goroutines)
Self-Containment Invariant
Two tests enforce the invariant per verb:
Central guard (e.g. cmd/show/yang/self_containment_test.go):
a banned-token map listing every owner-migrated command. If any banned token
appears in the central YANG, the test fails.
Owner presence (e.g. plugins/host/yang/self_containment_test.go):
asserts the owner's YANG declares its commands. If the owner is deleted, the
presence test vanishes with it (correct). If someone moves the command back to
central, the presence test fails (incorrect, caught).
When carving a new command out of a central schema:
- Add the banned token to the central guard test
- Add the presence assertion to the owner's YANG test
- Both halves must exist before the carve is complete
Handler Registration
Handlers register via pluginserver.RegisterRPCs() in an init() function
inside the plugin's cmd/ package:
func init() {
pluginserver.RegisterRPCs(
pluginserver.RPCRegistration{
WireMethod: "ze-show:host-cpu",
Handler: handleShowHostCPU,
},
)
}
The package must be blank-imported (directly or transitively) from
internal/component/plugin/all/all.go. The codegen scans for packages
containing pluginserver.RegisterRPCs or yang.RegisterModule calls and
generates the import list.
Summary: Where Things Go
| Artifact | Location | Hand-written? |
|---|---|---|
| Implementation library | Shared code: component/<name>/ or core/<name>/Full-subsystem plugin: plugins/<name>/ |
✓ |
| Config YANG (data model) | Shared subsystem: component/<name>/yang/Full-subsystem plugin: plugins/<name>/yang/ |
✓ |
| Command YANG (CLI tree) | plugins/<name>/yang/ |
✓ |
| YANG embed + register | plugins/<name>/yang/ |
Generated |
| RPC handlers | plugins/<name>/cmd/ |
✓ |
| Offline CLI registration | plugins/<name>/register.go |
✓ |
| Blank imports | all.go |
Generated |