Build, Test, and Debug
Test plugin logic directly first, then prove the shared-object boundary with a separate load helper, and finally verify the artifact against the exact server build that will deploy it.
Reproducible build contract
Current Nauthilus container builds compile the server and bundled plugins from one source tree with:
- Go 1.27 from the repository toolchain declaration;
GOEXPERIMENT=runtimesecret;- vendored dependencies;
netgoplus any selected release build tags;-trimpath;- the same
github.com/croessner/nauthilus/v4package sources; - one shared native artifact identity passed through
-ldflags.
A corresponding plugin build is:
export GOEXPERIMENT=runtimesecret
NATIVE_ARTIFACT_LDFLAGS="$(cd /path/to/nauthilus && \
go run -mod=vendor ./scripts/native_artifact_fingerprint -tags="netgo ${BUILD_TAGS}")"
go build \
-mod=vendor \
-tags="netgo ${BUILD_TAGS}" \
-trimpath \
-ldflags "${NATIVE_ARTIFACT_LDFLAGS}" \
-buildmode=plugin \
-o build/customer.so \
.
An external plugin can use module mode instead of vendoring, but its selected versions still have to match the host. Record the host tag or commit, Go version, build tags, and artifact hash in your release metadata.
Native artifact identity
Nauthilus rejects any native artifact that does not carry the host's exact build identity. The identity is produced by
scripts/native_artifact_fingerprint in the Nauthilus source tree. It hashes the Go toolchain settings (GOVERSION,
GOOS, GOARCH, CGO_ENABLED, GOEXPERIMENT, GOFLAGS, GOAMD64, GOARM64, CGO flags, CC, CXX), the sorted
build tags, every tracked or untracked non-ignored Go, C/C++, assembly, and .syso source file, go.mod, go.sum,
vendor/modules.txt, and the resources embedded by the server, the bundled plugins, and the sample plugin. The tool prints one -ldflags value:
-X github.com/croessner/nauthilus/v4/pluginapi/v1.nativeArtifactIdentity=nauthilus-native-artifact-v1:<sha256>:end-native-artifact
The repository Makefile, the geoip and dkim2-intelligence plugin Makefiles under contrib/plugins, and the
container builds (which build all five bundled plugins) already pass this value.
For your own build:
- Build the server and every plugin from one unchanged Nauthilus source snapshot, with the same toolchain, target
environment, and tags. Set the target
GOOS/GOARCHand CGO environment before running the fingerprint tool. - Pass the same
-ldflagsvalue to the server build and to each plugin build. - Sign the plugins and record checksums only after the build.
- Publish server, plugins, signatures, and configuration as one immutable bundle and restart the process to activate it. Keep the previous complete bundle for rollback; never mix old plugin files into a new image.
The loader compares every identity string embedded in the .so with the host identity before plugin.Open. A missing
(unmarked), different, or mixed identity fails with "native artifact does not match the host build identity"; Go's own
package-hash check follows afterwards. Setting Metadata().Build.ArtifactIdentity to
pluginapi.NativeArtifactIdentity() records the identity in discovery output but is diagnostic only.
In a Nauthilus checkout, sh scripts/check-native-artifact-bundle.sh builds a disposable probe plus the sample and
bundled plugin fixtures, and proves that a stale and an unmarked artifact are rejected. It publishes nothing and starts no
plugin services.
Why API version is not enough
The loader first has to satisfy Go's shared-package compatibility checks. Only then can Nauthilus inspect Metadata().APIVersion. These are separate layers:
| Layer | What must match |
|---|---|
| Go plugin loader | toolchain/package hashes, experiments, build tags, shared sources, dependency graph |
| Nauthilus contract | factory signature, non-nil plugin, metadata, exact pluginapi.APIVersion |
| Operator policy | allowlisted resolved path, checksum/signature/trust policy, capabilities |
| Runtime registration | unique names, descriptors, facts, hooks, and strict plugin config |
built with a different version of package ... is an ABI/package-hash failure. Rebuild against the exact host source and dependency graph; changing only Metadata().APIVersion cannot fix it.
Unit tests without .so loading
Most behavior should be tested as ordinary Go code:
func TestRiskSource(t *testing.T) {
source := riskSource{}
request := pluginapi.EnvironmentRequest{
Snapshot: pluginapi.RequestSnapshot{
ClientNet: "203.0.113.0/24",
},
}
result, err := source.Evaluate(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if !result.Triggered {
t.Fatal("expected source to trigger")
}
}
Recommended test layers:
- typed config decode/default/validation tests;
- table-driven component behavior tests;
- cancellation, timeout, and error-redaction tests;
- fake facade tests for HTTP, Redis, LDAP, mail, metrics, and tracing;
- reload tests that prove failed preparation leaves old state active;
- concurrency/race tests for shared module state;
- one artifact build/load smoke test.
External plugins should implement narrow fakes for public interfaces instead of importing server/pluginruntime or server/pluginregistry. In-tree reference plugins may use internal test helpers because they are built and tested with the host repository.
Run current Nauthilus-compatible tests with:
GOEXPERIMENT=runtimesecret GOCACHE=/tmp/nauthilus-go-cache go test ./...
GOEXPERIMENT=runtimesecret GOCACHE=/tmp/nauthilus-go-cache go test -race ./...
Compile-time interface assertions
Keep assertions next to implementations:
var (
_ pluginapi.Plugin = (*Plugin)(nil)
_ pluginapi.RuntimePlugin = (*Plugin)(nil)
_ pluginapi.ReloadablePlugin = (*Plugin)(nil)
_ pluginapi.Backend = (*Backend)(nil)
_ pluginapi.WebAuthnBackend = (*Backend)(nil)
_ pluginapi.EnvironmentSource = (*Environment)(nil)
_ pluginapi.PostActionTarget = (*PostAction)(nil)
)
Optional backend interfaces are independent. Assert only the operations the backend truly owns; the host detects each interface separately.
Shared-object smoke test
Do not load a freshly built plugin into the same go test process that also imports another build of the shared packages. That pattern often creates a misleading package-version mismatch.
Use a small helper program compiled in a separate process:
package main
import (
"fmt"
"os"
"plugin"
pluginapi "github.com/croessner/nauthilus/v4/pluginapi/v1"
)
func main() {
artifact, err := plugin.Open(os.Args[1])
if err != nil {
panic(err)
}
symbol, err := artifact.Lookup("NauthilusPlugin")
if err != nil {
panic(err)
}
factory, ok := symbol.(func() (pluginapi.Plugin, error))
if !ok {
panic("wrong NauthilusPlugin signature")
}
instance, err := factory()
if err != nil {
panic(err)
}
fmt.Println(instance.Metadata().Name)
}
Build the artifact, then invoke the helper with go run ./testdata/loadplugin build/customer.so. The GeoIP reference plugin uses this subprocess pattern.
Skip the smoke test explicitly on operating systems or architectures where Go plugin build mode is unsupported. Do not silently treat an unsupported platform as a successful load proof.
Config and registration tests
Strict config decode should be part of the public plugin contract:
- unknown keys fail;
- invalid durations and enum values fail;
- relative or unsafe plugin-owned file paths fail according to plugin policy;
- a reload error preserves the previous state;
- a capability-dependent feature fails registration when the capability was not acquired;
- duplicate component/fact/debug names fail.
Test one minimal config and one full config from the published documentation to keep examples executable.
Runtime-value tests
Test that outputs contain only safe value graphs. Include negative cases for:
- Go pointers or function/channel values in
RuntimeDelta; - unregistered or wrong-type policy facts;
- invalid backend attribute names;
- forbidden response headers;
- credential-bearing logs/status/errors;
- mutation of input maps or slices that must not affect retained state.
For post-actions, test plan ordering and prove that a step's delta is visible to the next step but absent from live request runtime after the detached plan ends.
Debug selectors
Enable the normal debug level and the narrowest useful selector:
observability:
log:
level: debug
debug_modules:
- plugin.customer_sql.lookup
The module automatically has plugin.<module>. Extra local selectors appear only after RegisterDebugModule succeeds. Keep Info, Warn, and Error useful without debug mode; use debug records for bounded diagnostic detail.
Host-owned diagnostics
Look for lifecycle events in this order:
- artifact verification started/completed;
- factory lookup and metadata validation;
- registration commit;
- runtime start and init-task start;
- request-call result metrics/spans;
- bounded stop and worker completion.
Automatic call metrics are:
plugin_calls_total{module,component,extension_point,method,result}
plugin_call_duration_seconds{module,component,extension_point,method,result}
Use these to distinguish registration/startup failures from request-time plugin failures before enabling plugin-specific debug output.
Interactive debugging with Delve
When logs and assertions are not enough, Delve (dlv) can inspect state, call stacks, goroutines, and breakpoints. Start
from a focused test or a sanitized local server configuration, and keep the Go experiment that the build requires:
GOEXPERIMENT=runtimesecret dlv test ./server/package -- -test.run TestName -test.count=1
GOEXPERIMENT=runtimesecret dlv debug ./server -- -config /path/to/local.yml
Do not attach a debugger to production processes without explicit operator approval. Keep the reproducer as a regression test; a debugger session supplements but does not replace it.
Reference test assets
pluginapi/v1/testdata/samplepluginis the public compile-contract fixture.contrib/plugins/geoipdemonstrates.sobuild/load testing and lifecycle fakes.contrib/plugins/clickhouseandcontrib/plugins/haveibeenpwnddemonstrate post-action plan/runtime exchange tests.
See API validation for exact public name and metadata validators.