This is an automated email from the ASF dual-hosted git repository.
Startrekzky pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/devlake.git
The following commit(s) were added to refs/heads/main by this push:
new a69a954f7 feat(build): link plugins into the server binary via a
generated registry (#9288)
a69a954f7 is described below
commit a69a954f799d0795e610984a647cd1bef1672cc1
Author: Klesh Wong <[email protected]>
AuthorDate: Sat Oct 31 15:15:19 2026 +0800
feat(build): link plugins into the server binary via a generated registry
(#9288)
* feat(build): link plugins into the server binary via a generated registry
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.
* feat(plugin): treat sideloaded .so plugins as a supported extension path
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.
* build(image): ship one binary and keep the .so files out of the image
(#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.
---
.github/workflows/test.yml | 5 +
Makefile | 5 +-
backend/Dockerfile | 10 +-
backend/Dockerfile.local | 16 +-
backend/Makefile | 23 +-
backend/core/plugin/hub.go | 21 ++
backend/core/runner/loader.go | 78 ++++--
backend/plugins/all/all.go | 131 ++++++++++
.../{server/main.go => plugins/all/external.go} | 28 +-
backend/scripts/build-server.sh | 6 +-
backend/server/main.go | 3 +
backend/tools/pluginsgen/main.go | 282 +++++++++++++++++++++
env.example | 5 +-
13 files changed, 554 insertions(+), 59 deletions(-)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 21dfc9653..86e2ea95e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -56,6 +56,11 @@ jobs:
cd backend
go mod tidy
git diff --exit-code -- go.mod go.sum
+ - name: Check plugins/all is up to date
+ run: |
+ cd backend
+ go run ./tools/pluginsgen
+ git diff --exit-code -- plugins/all
- name: Install mockery 3.8.1
run: backend/scripts/install-mockery.sh
- name: Build Python
diff --git a/Makefile b/Makefile
index 8639ce2dc..cd74395f9 100644
--- a/Makefile
+++ b/Makefile
@@ -71,10 +71,13 @@ swag:
build-plugin:
make build-plugin -C backend
+plugins-registry:
+ make plugins-registry -C backend
+
build-server:
make build-server -C backend
-build: build-plugin build-server
+build: build-server
all: build
diff --git a/backend/Dockerfile b/backend/Dockerfile
index b85a5ec8d..2c31a3d53 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -168,18 +168,22 @@ ARG DEBUG=
ENV LD_LIBRARY_PATH=/app/libs
RUN mkdir -p /app/libs
COPY --from=build /usr/local/deps/target/lib/*.so* /app/libs
-COPY --from=build /go/bin /usr/bin
RUN ldconfig -vn /app/libs
# apps
-COPY --from=build /app/bin /app/bin
+# The single binary already contains every plugin; the .so files that
+# `make build-plugin` produces stay in the builder stage and are never copied.
+COPY --from=build /app/bin/lake /app/bin/lake
COPY --from=build /app/resources /app/resources
ENV PATH="/app/bin:${PATH}"
ENV DEBUG="$DEBUG"
USER root
-RUN chgrp -R 0 /app && \
+# Empty mount point for sideloaded plugins: dropping a .so here registers it on
+# top of the compiled-in set at startup; see PLUGIN_DIR.
+RUN mkdir -p /app/bin/plugins && \
+ chgrp -R 0 /app && \
chmod -R g=u /app
USER devlake
diff --git a/backend/Dockerfile.local b/backend/Dockerfile.local
index 8651900d5..e2046db3b 100644
--- a/backend/Dockerfile.local
+++ b/backend/Dockerfile.local
@@ -72,15 +72,12 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
make swag
-# Build plugins (parallel, with persistent Go build cache)
+# Build the server. `build` is `build-server`: the framework, the API and every
+# plugin in the generated registry link into this one binary. No .so files are
+# produced for the release image.
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
- make build-plugin
-
-# Build server
-RUN --mount=type=cache,target=/root/.cache/go-build \
- --mount=type=cache,target=/go/pkg/mod \
- VERSION=${TAG}@${SHA} make build-server
+ VERSION=${TAG}@${SHA} make build
FROM debian:bookworm-slim
@@ -102,7 +99,10 @@ RUN ldconfig
WORKDIR /app
-COPY --from=builder /app/bin ./bin
+COPY --from=builder /app/bin/lake ./bin/lake
+
+# Empty mount point for sideloaded plugins
+RUN mkdir -p bin/plugins
# Create empty python plugins dir to avoid startup error
RUN mkdir -p python/plugins
diff --git a/backend/Makefile b/backend/Makefile
index 3e40d4c00..e8bb9128e 100644
--- a/backend/Makefile
+++ b/backend/Makefile
@@ -54,27 +54,42 @@ dep: go-dep python-dep
swag: mock
scripts/swag.sh
+# Regenerate plugins/all, the package that links the Go plugins into the server
+# binary. Run it after adding, removing or renaming a plugin; CI checks that
the
+# checked-in file matches the tree.
+#
+# GOOS/GOARCH are cleared because `backend/Dockerfile` exports them to
+# cross-compile the server: `go run` would otherwise build the generator for
the
+# target platform and fail with "exec format error" on the build host. Same
+# reason as MOCKERY above.
+plugins-registry:
+ GOOS= GOARCH= CGO_ENABLED=0 go run ./tools/pluginsgen
+
build-plugin:
scripts/build-plugins.sh
build-plugin-debug:
DEVLAKE_DEBUG=1 scripts/build-plugins.sh
-build-server: swag
+build-server: plugins-registry swag
VERSION=$(VERSION) scripts/build-server.sh
build-python: #don't mix this with the other build commands
scripts/build-python.sh
-build: build-plugin build-server
+# `build` produces the only artifact the release ships: one binary with the
+# framework, the API and every plugin in the generated registry compiled in.
+# Use build-plugin when you want shared objects instead, either to sideload
+# them into an existing binary or to ship a core-only server.
+build: build-server
run:
go run server/main.go
-dev: build-plugin build-python run
+dev: build-python run
godev:
- DISABLED_REMOTE_PLUGINS=true make build-plugin run
+ DISABLED_REMOTE_PLUGINS=true make run
debug: build-plugin-debug
dlv debug server/main.go
diff --git a/backend/core/plugin/hub.go b/backend/core/plugin/hub.go
index d043600c1..12d89ab17 100644
--- a/backend/core/plugin/hub.go
+++ b/backend/core/plugin/hub.go
@@ -43,6 +43,27 @@ func RegisterPlugin(name string, plugin PluginMeta)
errors.Error {
return nil
}
+// MustRegister registers p under the name it reports, panicking if the plugin
+// cannot be identified.
+//
+// The generated plugins/all package calls it for every plugin that is compiled
+// into the binary, which makes the plugin set a build-time decision.
Registering
+// a name twice overwrites the previous entry, so a plugin that is both
compiled
+// in and loaded from a shared object keeps working: the loader registers the
+// same value again.
+func MustRegister(p PluginMeta) {
+ if p == nil {
+ panic("plugin: MustRegister called with a nil plugin")
+ }
+ name := p.Name()
+ if name == "" {
+ panic("plugin: MustRegister called with an empty plugin name")
+ }
+ if err := RegisterPlugin(name, p); err != nil {
+ panic(err)
+ }
+}
+
func GetPlugin(name string) (PluginMeta, errors.Error) {
if plugins == nil {
return nil, errors.Default.New("RegisterPlugin have never been
called.")
diff --git a/backend/core/runner/loader.go b/backend/core/runner/loader.go
index f05a78632..a19ba329f 100644
--- a/backend/core/runner/loader.go
+++ b/backend/core/runner/loader.go
@@ -20,6 +20,7 @@ package runner
import (
"fmt"
"io/fs"
+ "os"
"path/filepath"
goplugin "plugin"
"strings"
@@ -50,44 +51,71 @@ func LoadPlugins(basicRes context.BasicRes) errors.Error {
func LoadGoPlugins(basicRes context.BasicRes) errors.Error {
pluginsDir := basicRes.GetConfig("PLUGIN_DIR")
+ if pluginsDir == "" {
+ return nil
+ }
+ // The official binary already has every official plugin compiled in,
so a
+ // missing directory simply means "nothing to sideload".
+ if _, statErr := os.Stat(pluginsDir); os.IsNotExist(statErr) {
+ basicRes.GetLogger().Info("plugin directory %s does not exist,
no plugins to sideload", pluginsDir)
+ return nil
+ }
+
var wg sync.WaitGroup
walkErr := filepath.WalkDir(pluginsDir, func(path string, d
fs.DirEntry, err error) error {
if err != nil {
return err
}
fileName := d.Name()
- if strings.HasSuffix(fileName, ".so") && fileName != ".so" {
- pluginName := fileName[0 : len(d.Name())-3]
- plug, loadErr := goplugin.Open(path)
- if loadErr != nil {
- return loadErr
- }
- symPluginEntry, pluginEntryError :=
plug.Lookup("PluginEntry")
- if pluginEntryError != nil {
- return pluginEntryError
- }
- pluginMeta, ok := symPluginEntry.(plugin.PluginMeta)
- if !ok {
- return errors.Default.New(fmt.Sprintf("%s
PluginEntry must implement PluginMeta interface", pluginName))
- }
- wg.Add(1)
- go func(pluginName string, pluginMeta
plugin.PluginMeta) {
- defer func() {
- wg.Done()
- }()
- err = plugin.RegisterPlugin(pluginName,
pluginMeta)
- if err != nil {
- panic(err)
- }
- basicRes.GetLogger().Info(`plugin loaded %s`,
pluginName)
- }(pluginName, pluginMeta)
+ if !strings.HasSuffix(fileName, ".so") || fileName == ".so" {
+ return nil
+ }
+
+ pluginName := strings.TrimSuffix(fileName, ".so")
+ pluginMeta, loadErr := openGoPlugin(path)
+ if loadErr != nil {
+ // Sideloaded plugins are third-party code built
outside this repo. One
+ // incompatible or broken file must not keep the server
from starting,
+ // so it is skipped loudly instead. The usual cause is
a toolchain or
+ // dependency mismatch with the binary that is loading
it.
+ basicRes.GetLogger().Error(loadErr, "skipping plugin %s
from %s", pluginName, path)
+ return nil
+ }
+ if _, err := plugin.GetPlugin(pluginName); err == nil {
+ basicRes.GetLogger().Warn(nil, "plugin %s is already
registered, the shared object %s takes precedence", pluginName, path)
}
+
+ wg.Add(1)
+ go func(name string, meta plugin.PluginMeta) {
+ defer wg.Done()
+ if err := plugin.RegisterPlugin(name, meta); err != nil
{
+ panic(err)
+ }
+ basicRes.GetLogger().Info("plugin loaded %s", name)
+ }(pluginName, pluginMeta)
return nil
})
wg.Wait()
return errors.Convert(walkErr)
}
+// openGoPlugin opens a shared object and reads its exported entry point.
+func openGoPlugin(path string) (plugin.PluginMeta, error) {
+ plug, err := goplugin.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ symPluginEntry, err := plug.Lookup("PluginEntry")
+ if err != nil {
+ return nil, err
+ }
+ pluginMeta, ok := symPluginEntry.(plugin.PluginMeta)
+ if !ok {
+ return nil, fmt.Errorf("%s: PluginEntry must implement
plugin.PluginMeta", filepath.Base(path))
+ }
+ return pluginMeta, nil
+}
+
func LoadRemotePlugins(basicRes context.BasicRes) errors.Error {
remotePluginDir := basicRes.GetConfig("REMOTE_PLUGIN_DIR")
if remotePluginDir != "" {
diff --git a/backend/plugins/all/all.go b/backend/plugins/all/all.go
new file mode 100644
index 000000000..7fd68c5a1
--- /dev/null
+++ b/backend/plugins/all/all.go
@@ -0,0 +1,131 @@
+//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.
+*/
+
+// Code generated by tools/pluginsgen. DO NOT EDIT.
+
+// Package all links the official Go plugins into the server binary.
+//
+// Importing this package registers every plugin listed below and fixes the
+// official plugin set at build time. It does not close the door on extensions:
+// any .so found in PLUGIN_DIR is still opened at startup and registered on
+// top of these. Build with the devlake_external_plugins tag to leave this
+// registry empty and ship a core-only binary instead.
+package all
+
+import (
+ "github.com/apache/devlake/core/plugin"
+
+ ae "github.com/apache/devlake/plugins/ae/impl"
+ argocd "github.com/apache/devlake/plugins/argocd/impl"
+ asana "github.com/apache/devlake/plugins/asana/impl"
+ azuredevops_go "github.com/apache/devlake/plugins/azuredevops_go/impl"
+ bamboo "github.com/apache/devlake/plugins/bamboo/impl"
+ bitbucket "github.com/apache/devlake/plugins/bitbucket/impl"
+ bitbucket_server
"github.com/apache/devlake/plugins/bitbucket_server/impl"
+ circleci "github.com/apache/devlake/plugins/circleci/impl"
+ claude_code "github.com/apache/devlake/plugins/claude_code/impl"
+ clickup "github.com/apache/devlake/plugins/clickup/impl"
+ cursor "github.com/apache/devlake/plugins/cursor/impl"
+ customize "github.com/apache/devlake/plugins/customize/impl"
+ dora "github.com/apache/devlake/plugins/dora/impl"
+ feishu "github.com/apache/devlake/plugins/feishu/impl"
+ gh_copilot "github.com/apache/devlake/plugins/gh-copilot/impl"
+ gitee "github.com/apache/devlake/plugins/gitee/impl"
+ gitextractor "github.com/apache/devlake/plugins/gitextractor/impl"
+ github "github.com/apache/devlake/plugins/github/impl"
+ github_graphql "github.com/apache/devlake/plugins/github_graphql/impl"
+ gitlab "github.com/apache/devlake/plugins/gitlab/impl"
+ grafana_irm "github.com/apache/devlake/plugins/grafana_irm/impl"
+ icla "github.com/apache/devlake/plugins/icla/impl"
+ incidentio "github.com/apache/devlake/plugins/incidentio/impl"
+ issue_trace "github.com/apache/devlake/plugins/issue_trace/impl"
+ jenkins "github.com/apache/devlake/plugins/jenkins/impl"
+ jira "github.com/apache/devlake/plugins/jira/impl"
+ kiro "github.com/apache/devlake/plugins/kiro/impl"
+ linear "github.com/apache/devlake/plugins/linear/impl"
+ linker "github.com/apache/devlake/plugins/linker/impl"
+ monorepo "github.com/apache/devlake/plugins/monorepo/impl"
+ opsgenie "github.com/apache/devlake/plugins/opsgenie/impl"
+ org "github.com/apache/devlake/plugins/org/impl"
+ pagerduty "github.com/apache/devlake/plugins/pagerduty/impl"
+ refdiff "github.com/apache/devlake/plugins/refdiff/impl"
+ rootly "github.com/apache/devlake/plugins/rootly/impl"
+ slack "github.com/apache/devlake/plugins/slack/impl"
+ sonarqube "github.com/apache/devlake/plugins/sonarqube/impl"
+ starrocks "github.com/apache/devlake/plugins/starrocks/impl"
+ taiga "github.com/apache/devlake/plugins/taiga/impl"
+ tapd "github.com/apache/devlake/plugins/tapd/impl"
+ teambition "github.com/apache/devlake/plugins/teambition/impl"
+ tempo "github.com/apache/devlake/plugins/tempo/impl"
+ testmo "github.com/apache/devlake/plugins/testmo/impl"
+ trello "github.com/apache/devlake/plugins/trello/impl"
+ webhook "github.com/apache/devlake/plugins/webhook/impl"
+ youtrack "github.com/apache/devlake/plugins/youtrack/impl"
+ zentao "github.com/apache/devlake/plugins/zentao/impl"
+)
+
+func init() {
+ plugin.MustRegister(ae.AE{})
+ plugin.MustRegister(argocd.ArgoCD{})
+ plugin.MustRegister(asana.Asana{})
+ plugin.MustRegister(azuredevops_go.Azuredevops{})
+ plugin.MustRegister(bamboo.Bamboo{})
+ plugin.MustRegister(bitbucket.Bitbucket{})
+ plugin.MustRegister(bitbucket_server.BitbucketServer{})
+ plugin.MustRegister(circleci.Circleci{})
+ plugin.MustRegister(claude_code.ClaudeCode{})
+ plugin.MustRegister(clickup.ClickUp{})
+ plugin.MustRegister(cursor.Cursor{})
+ plugin.MustRegister(customize.Customize{})
+ plugin.MustRegister(dora.Dora{})
+ plugin.MustRegister(feishu.Feishu{})
+ plugin.MustRegister(gh_copilot.GhCopilot{})
+ plugin.MustRegister(gitee.Gitee{})
+ plugin.MustRegister(gitextractor.GitExtractor{})
+ plugin.MustRegister(github.Github{})
+ plugin.MustRegister(github_graphql.GithubGraphql{})
+ plugin.MustRegister(gitlab.Gitlab{})
+ plugin.MustRegister(grafana_irm.GrafanaIrm{})
+ plugin.MustRegister(icla.Icla{})
+ plugin.MustRegister(incidentio.Incidentio{})
+ plugin.MustRegister(issue_trace.IssueTrace{})
+ plugin.MustRegister(jenkins.Jenkins{})
+ plugin.MustRegister(jira.Jira{})
+ plugin.MustRegister(kiro.Kiro{})
+ plugin.MustRegister(linear.Linear{})
+ plugin.MustRegister(linker.Linker{})
+ plugin.MustRegister(monorepo.Monorepo{})
+ plugin.MustRegister(opsgenie.Opsgenie{})
+ plugin.MustRegister(org.Org{})
+ plugin.MustRegister(pagerduty.PagerDuty{})
+ plugin.MustRegister(refdiff.RefDiff{})
+ plugin.MustRegister(rootly.Rootly{})
+ plugin.MustRegister(slack.Slack{})
+ plugin.MustRegister(sonarqube.Sonarqube{})
+ plugin.MustRegister(starrocks.StarRocks{})
+ plugin.MustRegister(taiga.Taiga{})
+ plugin.MustRegister(tapd.Tapd{})
+ plugin.MustRegister(teambition.Teambition{})
+ plugin.MustRegister(tempo.Tempo{})
+ plugin.MustRegister(testmo.Testmo{})
+ plugin.MustRegister(trello.Trello{})
+ plugin.MustRegister(webhook.Webhook{})
+ plugin.MustRegister(youtrack.Youtrack{})
+ plugin.MustRegister(zentao.Zentao{})
+}
diff --git a/backend/server/main.go b/backend/plugins/all/external.go
similarity index 56%
copy from backend/server/main.go
copy to backend/plugins/all/external.go
index 7a52c6597..995ed3389 100644
--- a/backend/server/main.go
+++ b/backend/plugins/all/external.go
@@ -15,20 +15,16 @@ See the License for the specific language governing
permissions and
limitations under the License.
*/
-package main
+//go:build devlake_external_plugins
-import (
- "github.com/apache/devlake/core/config"
- "github.com/apache/devlake/core/plugin"
- _ "github.com/apache/devlake/core/version"
- "github.com/apache/devlake/server/api"
-)
-
-func main() {
- v := config.GetConfig()
- encryptionSecret := v.GetString(plugin.EncodeKeyEnvStr)
- if encryptionSecret == "" {
- panic("ENCRYPTION_SECRET must be set in environment variable or
.env file")
- }
- api.CreateAndRunApiServer()
-}
+// Package all is empty in a core-only build.
+//
+// A core-only binary compiles in the framework and the API but no plugin,
which
+// is useful for a minimal image: every plugin is then sideloaded as a shared
+// object from PLUGIN_DIR. The default build links the official plugins in and
+// still accepts extra .so files from PLUGIN_DIR on top.
+//
+// A sideloaded plugin must be built with the same Go toolchain, build flags
and
+// dependency versions as the binary loading it, so build it in the matching
+// builder image rather than on the host.
+package all
diff --git a/backend/scripts/build-server.sh b/backend/scripts/build-server.sh
index b390185c3..479e1fcfa 100755
--- a/backend/scripts/build-server.sh
+++ b/backend/scripts/build-server.sh
@@ -22,9 +22,13 @@ echo build-server.sh VERSION: $VERSION
ROOT_DIR=$(dirname $(dirname "$0"))
VERSION=${VERSION:-$(git describe --tags --always --dirty || true)}
EXTRA=""
+# Strip the symbol table and DWARF from release builds: they roughly halve the
+# binary and nothing needs them at runtime. A debug build keeps them for delve.
+LDFLAGS="-s -w"
if [ -n "$DEVLAKE_DEBUG" ]; then
EXTRA="-gcflags='all=-N -l'"
+ LDFLAGS=""
fi
-go build $EXTRA -ldflags "-X
'github.com/apache/devlake/core/version.Version=$VERSION'" -o
$ROOT_DIR/bin/lake $ROOT_DIR/server/
\ No newline at end of file
+go build $EXTRA -trimpath -ldflags "$LDFLAGS -X
'github.com/apache/devlake/core/version.Version=$VERSION'" -o
$ROOT_DIR/bin/lake $ROOT_DIR/server/
\ No newline at end of file
diff --git a/backend/server/main.go b/backend/server/main.go
index 7a52c6597..836b605e7 100644
--- a/backend/server/main.go
+++ b/backend/server/main.go
@@ -21,6 +21,9 @@ import (
"github.com/apache/devlake/core/config"
"github.com/apache/devlake/core/plugin"
_ "github.com/apache/devlake/core/version"
+ // Compile the official plugins into this binary and register them at
startup.
+ // Extra plugins can still be sideloaded from PLUGIN_DIR as shared
objects.
+ _ "github.com/apache/devlake/plugins/all"
"github.com/apache/devlake/server/api"
)
diff --git a/backend/tools/pluginsgen/main.go b/backend/tools/pluginsgen/main.go
new file mode 100644
index 000000000..719b2fcb4
--- /dev/null
+++ b/backend/tools/pluginsgen/main.go
@@ -0,0 +1,282 @@
+/*
+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.
+*/
+
+// Command pluginsgen generates plugins/all, the package that links the Go
+// plugins into the server binary.
+//
+// It scans plugins/*/impl for the type implementing plugin.PluginMeta and
emits
+// one import plus one plugin.MustRegister call per plugin. Keeping the
registry
+// generated means adding a plugin needs no edit to a shared list, and CI can
+// assert that the checked-in file matches the tree.
+package main
+
+import (
+ "bytes"
+ "flag"
+ "fmt"
+ "go/ast"
+ "go/format"
+ "go/parser"
+ "go/token"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+const modulePath = "github.com/apache/devlake"
+
+// licenseHeader is emitted verbatim; license-eye checks generated files too.
+const licenseHeader = `/*
+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.
+*/
+
+`
+
+// pluginMetaMethods are the methods declared by plugin.PluginMeta. Requiring
+// all of them keeps the scan from picking an unrelated Name method declared on
+// some other type in the same package.
+var pluginMetaMethods = []string{"Name", "Description", "RootPkgPath"}
+
+type plugin struct {
+ name string // directory name, e.g. gh-copilot
+ alias string // import alias, e.g. gh_copilot
+ typ string // type implementing plugin.PluginMeta
+}
+
+func main() {
+ root := flag.String("root", ".", "backend module root")
+ out := flag.String("o", "plugins/all/all.go", "output file, relative to
root")
+ subset := flag.String("plugins", os.Getenv("DEVLAKE_PLUGINS"),
"comma-separated plugin names to include (default: $DEVLAKE_PLUGINS, otherwise
all)")
+ flag.Parse()
+
+ if err := run(*root, *out, *subset); err != nil {
+ fmt.Fprintf(os.Stderr, "pluginsgen: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func run(root, out, subset string) error {
+ plugins, err := scan(root, subset)
+ if err != nil {
+ return err
+ }
+
+ src, err := render(plugins)
+ if err != nil {
+ return err
+ }
+
+ target := out
+ if !filepath.IsAbs(target) {
+ target = filepath.Join(root, out)
+ }
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return err
+ }
+ return os.WriteFile(target, src, 0o644)
+}
+
+// scan returns the plugins to link, sorted by name.
+func scan(root, subset string) ([]plugin, error) {
+ dirs, err := filepath.Glob(filepath.Join(root, "plugins", "*", "impl"))
+ if err != nil {
+ return nil, err
+ }
+ if len(dirs) == 0 {
+ return nil, fmt.Errorf("no plugins found under
%s/plugins/*/impl", root)
+ }
+
+ selected, explicit := parseSubset(subset)
+
+ var plugins []plugin
+ for _, dir := range dirs {
+ name := filepath.Base(filepath.Dir(dir))
+ if !selected(name) {
+ continue
+ }
+ typ, err := pluginMetaType(dir)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", name, err)
+ }
+ plugins = append(plugins, plugin{
+ name: name,
+ alias: strings.ReplaceAll(name, "-", "_"),
+ typ: typ,
+ })
+ }
+
+ // An explicit selection may legitimately match nothing ("none" builds a
+ // core with no compiled-in plugins); an unfiltered scan must not.
+ if len(plugins) == 0 && !explicit {
+ return nil, fmt.Errorf("no plugin found under
%s/plugins/*/impl", root)
+ }
+ sort.Slice(plugins, func(i, j int) bool { return plugins[i].name <
plugins[j].name })
+ return plugins, nil
+}
+
+// parseSubset builds the selection predicate from DEVLAKE_PLUGINS. An empty
+// value selects every plugin; "none" selects none, mirroring build-plugins.sh.
+// The second result reports whether a selection was requested at all.
+func parseSubset(subset string) (func(string) bool, bool) {
+ subset = strings.TrimSpace(subset)
+ switch subset {
+ case "":
+ return func(string) bool { return true }, false
+ case "none":
+ return func(string) bool { return false }, true
+ }
+
+ wanted := map[string]struct{}{}
+ for _, name := range strings.Split(subset, ",") {
+ if name = strings.TrimSpace(name); name != "" {
+ wanted[name] = struct{}{}
+ }
+ }
+ return func(name string) bool {
+ _, ok := wanted[name]
+ return ok
+ }, true
+}
+
+// pluginMetaType finds the type in dir whose method set covers
+// plugin.PluginMeta.
+func pluginMetaType(dir string) (string, error) {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return "", err
+ }
+
+ fset := token.NewFileSet()
+ methods := map[string]map[string]struct{}{}
+ for _, entry := range entries {
+ name := entry.Name()
+ if entry.IsDir() || !strings.HasSuffix(name, ".go") ||
strings.HasSuffix(name, "_test.go") {
+ continue
+ }
+ file, err := parser.ParseFile(fset, filepath.Join(dir, name),
nil, parser.SkipObjectResolution)
+ if err != nil {
+ return "", err
+ }
+ for _, decl := range file.Decls {
+ fn, ok := decl.(*ast.FuncDecl)
+ if !ok || fn.Recv == nil || len(fn.Recv.List) != 1 {
+ continue
+ }
+ receiver := receiverName(fn.Recv.List[0].Type)
+ if receiver == "" {
+ continue
+ }
+ if methods[receiver] == nil {
+ methods[receiver] = map[string]struct{}{}
+ }
+ methods[receiver][fn.Name.Name] = struct{}{}
+ }
+ }
+
+ var found []string
+ for receiver, declared := range methods {
+ implements := true
+ for _, method := range pluginMetaMethods {
+ if _, ok := declared[method]; !ok {
+ implements = false
+ break
+ }
+ }
+ if implements {
+ found = append(found, receiver)
+ }
+ }
+
+ switch len(found) {
+ case 1:
+ return found[0], nil
+ case 0:
+ return "", fmt.Errorf("no type implements plugin.PluginMeta")
+ default:
+ sort.Strings(found)
+ return "", fmt.Errorf("several types implement
plugin.PluginMeta: %s", strings.Join(found, ", "))
+ }
+}
+
+func receiverName(expr ast.Expr) string {
+ switch t := expr.(type) {
+ case *ast.Ident:
+ return t.Name
+ case *ast.StarExpr:
+ return receiverName(t.X)
+ case *ast.IndexExpr: // generic receiver, e.g. Foo[T]
+ return receiverName(t.X)
+ case *ast.IndexListExpr:
+ return receiverName(t.X)
+ }
+ return ""
+}
+
+func render(plugins []plugin) ([]byte, error) {
+ var buf bytes.Buffer
+ buf.WriteString(licenseHeader)
+ buf.WriteString("//go:build !devlake_external_plugins\n\n")
+ buf.WriteString("// Code generated by tools/pluginsgen. DO NOT
EDIT.\n\n")
+ buf.WriteString("// Package all links the official Go plugins into the
server binary.\n")
+ buf.WriteString("//\n")
+ buf.WriteString("// Importing this package registers every plugin
listed below and fixes the\n")
+ buf.WriteString("// official plugin set at build time. It does not
close the door on extensions:\n")
+ buf.WriteString("// any .so found in PLUGIN_DIR is still opened at
startup and registered on\n")
+ buf.WriteString("// top of these. Build with the
devlake_external_plugins tag to leave this\n")
+ buf.WriteString("// registry empty and ship a core-only binary
instead.\n")
+ buf.WriteString("package all\n\n")
+
+ if len(plugins) == 0 {
+ src, err := format.Source(buf.Bytes())
+ if err != nil {
+ return nil, fmt.Errorf("formatting generated source:
%w", err)
+ }
+ return src, nil
+ }
+
+ buf.WriteString("import (\n")
+ buf.WriteString("\t\"github.com/apache/devlake/core/plugin\"\n\n")
+ for _, p := range plugins {
+ fmt.Fprintf(&buf, "\t%s \"%s/plugins/%s/impl\"\n", p.alias,
modulePath, p.name)
+ }
+ buf.WriteString(")\n\n")
+ buf.WriteString("func init() {\n")
+ for _, p := range plugins {
+ fmt.Fprintf(&buf, "\tplugin.MustRegister(%s.%s{})\n", p.alias,
p.typ)
+ }
+ buf.WriteString("}\n")
+
+ src, err := format.Source(buf.Bytes())
+ if err != nil {
+ return nil, fmt.Errorf("formatting generated source: %w", err)
+ }
+ return src, nil
+}
diff --git a/env.example b/env.example
index effca30db..68be8d6d2 100755
--- a/env.example
+++ b/env.example
@@ -46,7 +46,10 @@
# certificates) on the dashboard container to file paths mounted into it.
# GRAFANA_DATABASE_SSL_MODE=disable
-# Lake plugin dir, absolute path or relative path
+# Lake plugin dir, absolute path or relative path.
+# The release binary already contains every official plugin. This directory is
+# scanned at startup for sideloaded .so plugins, which are registered on top of
+# the compiled-in set.
PLUGIN_DIR=bin/plugins
REMOTE_PLUGIN_DIR=python/plugins