Skip to content

feat(build): link plugins into the server binary via a generated registry - #9288

Merged
Startrekzky merged 3 commits into
mainfrom
feat/single-binary
Oct 31, 2026
Merged

Startrekzky merged 3 commits into
mainfrom
feat/single-binary

Conversation

@klesh

@klesh klesh commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

First step of #9276. Plugins were only discoverable at runtime, through the .so files in PLUGIN_DIR; this adds the compile-time path so the whole plugin set can be linked into one binary.

  • core/plugin.MustRegister registers a plugin under the name it reports, panicking on a nil plugin or an empty name. Overwrite semantics are unchanged, so a plugin that is both compiled in and loaded from a .so keeps working.
  • tools/pluginsgen scans plugins/*/impl for the type whose method set covers plugin.PluginMeta (Name, Description, RootPkgPath) and generates plugins/all/all.go, which imports each plugin and registers it from init. It fails loudly when a package has no such type or has several.
  • plugins/all/external.go is the devlake_external_plugins build-tag counterpart, for a plugin-free core binary that loads .so files instead.
  • server/main.go blank-imports the registry — the one framework→plugin edge the layering guard permits.
  • runner.LoadGoPlugins is hardened for third-party code. A missing PLUGIN_DIR means "nothing to sideload" instead of a boot failure; a .so that fails to open, has no PluginEntry or does not implement PluginMeta is logged and skipped rather than aborting startup; and a .so that reuses a compiled-in plugin's name overrides it with a warning, which is what makes patching an official plugin possible. The per-file open/lookup/assert sequence moved into openGoPlugin, which also removes the shared err the old goroutine captured.
  • CI regenerates the registry and fails when the checked-in file drifts, next to the existing go mod tidy check.

DEVLAKE_PLUGINS is honoured by the generator, so jira,gitlab produces a two-plugin registry and none produces an empty one — the same values scripts/build-plugins.sh already accepts.

Does this close any open issues?

Part of #9276. Build scripts and Dockerfiles are deliberately untouched here: make build still produces the .so files, and they register over the compiled-in ones harmlessly. Repackaging follows in a separate PR.

Screenshots

N/A

Other Information

Verified locally:

go build ./server                                  # ok
go build -tags devlake_external_plugins ./server    # ok, empty registry
DEVLAKE_PLUGINS=jira,gitlab go run ./tools/pluginsgen -o /tmp/subset.go
DEVLAKE_PLUGINS=none go run ./tools/pluginsgen -o /tmp/none.go

A throwaway test asserting len(plugin.AllPlugins()) == 47 passed, including the awkward names (gh-copilot, grafana_irm, issue_trace, gitextractor), and regeneration is deterministic.

The shipping model this sets up: the official binary has every official plugin compiled in, and PLUGIN_DIR remains the supported place to sideload extra or replacement .so plugins. Sideloading has a hard constraint worth flagging — a plugin must be built with the same toolchain, flags and dependency versions as the binary loading it, or Go refuses it with plugin was built with a different version of package .... The follow-up should publish the build recipe (the lake-builder image already exists) and add an explicit APIVersion/PluginAPIVersion handshake so that failure is legible. The loader now skips a broken .so instead of taking the server down, so a mismatch costs one plugin, not the whole boot.

The registry is what makes the single binary possible: with it linked, an unstripped go build ./server is 149 MB — the same plugin set as ~3.0 GB of .so files. Stripping and the Dockerfile changes are the next PR.

@klesh klesh added the pr-type/feature-development This PR is to develop a new feature label Oct 30, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

license-eye has checked 4817 files.

Valid Invalid Ignored Fixed
3728 1 1088 0
Click to see the invalid file list
  • backend/plugins/all/all.go
Use this command to fix any missing license headers
```bash

docker run -it --rm -v $(pwd):/github/workspace apache/skywalking-eyes header fix

</details>

@@ -0,0 +1,113 @@
//go:build !devlake_external_plugins

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
//go:build !devlake_external_plugins
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you 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.
//go:build !devlake_external_plugins

…stry

Plugins were only discoverable at runtime, through the .so files in
PLUGIN_DIR. Add a compile-time path so the whole plugin set can be linked
into one binary:

- core/plugin.MustRegister registers a plugin under the name it reports.
- tools/pluginsgen scans plugins/*/impl for the type implementing
  plugin.PluginMeta and generates plugins/all, which imports each plugin
  and registers it from an init function. It honours DEVLAKE_PLUGINS so
  subset builds keep working, and emits an empty package for "none".
- server/main.go blank-imports the registry. Building with
  -tags devlake_external_plugins swaps in an empty package for the
  shared-object-only build.
- runner.LoadGoPlugins treats a missing PLUGIN_DIR as "no shared-object
  plugins" instead of failing startup, so a single-binary deployment does
  not need to ship an empty directory.
- CI regenerates the registry and fails when the checked-in file drifts.

Build scripts and Dockerfiles are deliberately untouched: `make build`
still produces the .so files, and they register over the compiled-in ones
harmlessly. Repackaging follows separately.
@klesh
klesh force-pushed the feat/single-binary branch from 2997094 to 9217e30 Compare October 30, 2026 07:40
Reframe the runtime loader around the shipping model: the official binary
compiles in every official plugin, and PLUGIN_DIR is where users drop
additional or replacement plugins.

- A broken or incompatible .so is logged and skipped instead of aborting
  startup. Sideloaded plugins are third-party code built outside this
  repo; one bad file must not take the server down.
- A shared object reporting a name that is already registered (an official
  plugin) overrides it, with a warning. That is the existing behaviour and
  it is what makes patching an official plugin possible, so keep it but
  make it visible.
- Extract open/lookup/assert into openGoPlugin and stop the loader
  goroutine from writing the walk's `err` variable.
- Update the generated registry and the core-only build tag docs to
  describe sideloading rather than presenting .so as a legacy fallback.
…#9291)

`make build` is now `build-server`: the framework, the API and every plugin in
the generated registry link into a single bin/lake, so the runtime stage has no
plugin directory to copy.

- backend/Dockerfile: copy only /app/bin/lake instead of the whole bin/, and
  create an empty /app/bin/plugins as the sideload mount point. It is created
  as root, before the chgrp that makes /app group-writable, because the stage
  otherwise runs as the devlake user.
- Drop `COPY --from=build /go/bin /usr/bin`: mockery and swag are build tools
  and nothing at runtime uses them.
- Dockerfile.local: one build step instead of build-plugin plus build-server,
  and copy only the binary.
- build-server.sh: add -trimpath and -s -w. A DEVLAKE_DEBUG build keeps the
  symbols so delve still works.
- Makefile: build, dev and godev no longer build plugins; build-plugin stays
  for sideload and core-only builds.
- env.example: PLUGIN_DIR now documents the sideload directory.

GO_PLUGINS keeps working: it reaches the registry generator, so it now selects
which plugins are compiled in rather than which .so files are built.

plugins-registry clears GOOS/GOARCH: the Dockerfile exports them to
cross-compile the server, and `go run` would otherwise build the generator for
the target platform and fail with "exec format error" on an amd64 build host.

@Startrekzky Startrekzky left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@Startrekzky
Startrekzky merged commit a69a954 into main Oct 31, 2026
11 checks passed
@Startrekzky
Startrekzky deleted the feat/single-binary branch October 31, 2026 07:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-type/feature-development This PR is to develop a new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants