You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ossie.apache.org by jb...@apache.org on 2026/07/13 16:16:05 UTC
(ossie) 01/10: feat(cli): add plugin discovery infrastructure
This is an automated email from the ASF dual-hosted git repository.
jbonofre pushed a commit to branch pr-6
in repository https://gitbox.apache.org/repos/asf/ossie.git
commit d11a37524cfedd6fce5b8e7d9adae8938505d24d
Author: Quigley Malcolm <qu...@dbtlabs.com>
AuthorDate: Mon Jun 15 10:11:37 2026 -0500
feat(cli): add plugin discovery infrastructure
Downstream segments (convert, plugin list, plugin install) all need
a way to find and parse installed plugins. This adds the shared
foundation: a typed Plugin struct and a Discover() function that
scans ~/.ossie/plugins/ for valid plugin.yaml files.
Design decisions:
- internal/plugin accepts pluginsDir as a parameter rather than
calling ossiedir.PluginDir() internally — keeps the package
testable without env var manipulation
- plugin.yaml supports both ossie_* (preferred) and osi_* (deprecated
fallback) key families to avoid breaking plugins written before the
OSI → OSSIE rename; ossie_* wins when both are present
- Malformed plugin dirs emit a warning to an io.Writer and are
skipped; only an unreadable plugins directory is a hard error,
matching the behavior specified in the architecture doc
- Non-directory entries in the plugins dir are silently skipped
(no warning); non-existent dir returns nil, nil
Caveats:
- Symlinked plugin directories are not yet handled — os.ReadDir
reports symlinks with ModeSymlink, not IsDir(); a TODO comment
marks the location for a future follow-up
- cmd/plugin/list.go wiring is deferred to P1, which also requires
the embedded registry before the full table output can be rendered
---
cli/go.mod | 5 +-
cli/go.sum | 3 +
cli/internal/plugin/discover.go | 72 +++++++++
cli/internal/plugin/discover_test.go | 299 +++++++++++++++++++++++++++++++++++
cli/internal/plugin/plugin.go | 123 ++++++++++++++
5 files changed, 501 insertions(+), 1 deletion(-)
diff --git a/cli/go.mod b/cli/go.mod
index a828425..d17729d 100644
--- a/cli/go.mod
+++ b/cli/go.mod
@@ -2,7 +2,10 @@ module github.com/open-semantic-interchange/ossie/cli
go 1.22
-require github.com/spf13/cobra v1.10.2
+require (
+ github.com/spf13/cobra v1.10.2
+ gopkg.in/yaml.v3 v3.0.1
+)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
diff --git a/cli/go.sum b/cli/go.sum
index a6ee3e0..47edb24 100644
--- a/cli/go.sum
+++ b/cli/go.sum
@@ -7,4 +7,7 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/cli/internal/plugin/discover.go b/cli/internal/plugin/discover.go
new file mode 100644
index 0000000..700dec1
--- /dev/null
+++ b/cli/internal/plugin/discover.go
@@ -0,0 +1,72 @@
+package plugin
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+
+ "gopkg.in/yaml.v3"
+)
+
+const pluginYAML = "plugin.yaml"
+
+// Discover scans pluginsDir for subdirectories that contain a plugin.yaml,
+// parses and validates each one, and returns all valid plugins.
+//
+// Malformed or invalid plugin directories are skipped with a warning written
+// to stderr in the format:
+//
+// warning: skipping plugin at <path>: <reason>
+//
+// A non-existent pluginsDir is treated as empty and returns (nil, nil).
+// A hard error is only returned if pluginsDir itself cannot be read.
+func Discover(pluginsDir string, stderr io.Writer) ([]*Plugin, error) {
+ entries, err := os.ReadDir(pluginsDir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("could not read plugin directory %s: %w", pluginsDir, err)
+ }
+
+ var plugins []*Plugin
+ for _, entry := range entries {
+ // TODO: handle symlinked plugin directories
+ // (entry.Type()&os.ModeSymlink != 0 requires os.Stat to resolve)
+ if !entry.IsDir() {
+ continue
+ }
+ pluginPath := filepath.Join(pluginsDir, entry.Name())
+ p, err := loadPlugin(pluginPath)
+ if err != nil {
+ fmt.Fprintf(stderr, "warning: skipping plugin at %s: %s\n", pluginPath, err)
+ continue
+ }
+ plugins = append(plugins, p)
+ }
+ return plugins, nil
+}
+
+// loadPlugin reads, parses, and validates the plugin.yaml inside dir.
+func loadPlugin(dir string) (*Plugin, error) {
+ yamlPath := filepath.Join(dir, pluginYAML)
+ data, err := os.ReadFile(yamlPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, fmt.Errorf("no plugin.yaml found")
+ }
+ return nil, fmt.Errorf("could not read plugin.yaml: %w", err)
+ }
+
+ var raw rawPlugin
+ if err := yaml.Unmarshal(data, &raw); err != nil {
+ return nil, fmt.Errorf("invalid YAML: %w", err)
+ }
+
+ if err := raw.validate(); err != nil {
+ return nil, err
+ }
+
+ return raw.toPlugin(dir), nil
+}
diff --git a/cli/internal/plugin/discover_test.go b/cli/internal/plugin/discover_test.go
new file mode 100644
index 0000000..1ed7ce6
--- /dev/null
+++ b/cli/internal/plugin/discover_test.go
@@ -0,0 +1,299 @@
+package plugin_test
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/open-semantic-interchange/ossie/cli/internal/plugin"
+)
+
+// validPluginYAML is the canonical plugin.yaml fixture using ossie_* keys.
+const validPluginYAML = `
+ossie_plugin_spec: "0.1.0"
+ossie_spec_version: ">=0.2.0"
+platform:
+ name: dbt
+ vendor: dbt Labs
+convert:
+ to_osi:
+ invoke: ["ossie-plugin-dbt", "to-osi"]
+ accepts: [".yaml", ".json"]
+ from_osi:
+ invoke: ["ossie-plugin-dbt", "from-osi"]
+`
+
+// writePlugin creates a plugin directory under root with the given plugin.yaml content.
+func writePlugin(t *testing.T, root, name, content string) string {
+ t.Helper()
+ dir := filepath.Join(root, name)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ t.Fatalf("could not create plugin dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "plugin.yaml"), []byte(content), 0644); err != nil {
+ t.Fatalf("could not write plugin.yaml: %v", err)
+ }
+ return dir
+}
+
+func TestDiscover_emptyDir(t *testing.T) {
+ dir := t.TempDir()
+ var stderr strings.Builder
+
+ plugins, err := plugin.Discover(dir, &stderr)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(plugins) != 0 {
+ t.Errorf("expected no plugins, got %d", len(plugins))
+ }
+ if stderr.Len() != 0 {
+ t.Errorf("expected no warnings, got: %q", stderr.String())
+ }
+}
+
+func TestDiscover_nonExistentDir(t *testing.T) {
+ var stderr strings.Builder
+
+ plugins, err := plugin.Discover("/this/path/does/not/exist", &stderr)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if plugins != nil {
+ t.Errorf("expected nil slice, got %v", plugins)
+ }
+}
+
+func TestDiscover_validPlugin(t *testing.T) {
+ root := t.TempDir()
+ pluginDir := writePlugin(t, root, "dbt", validPluginYAML)
+ var stderr strings.Builder
+
+ plugins, err := plugin.Discover(root, &stderr)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(plugins) != 1 {
+ t.Fatalf("expected 1 plugin, got %d", len(plugins))
+ }
+ p := plugins[0]
+ if p.Path != pluginDir {
+ t.Errorf("Path: got %q, want %q", p.Path, pluginDir)
+ }
+ if p.Platform.Name != "dbt" {
+ t.Errorf("Platform.Name: got %q, want %q", p.Platform.Name, "dbt")
+ }
+ if p.Platform.Vendor != "dbt Labs" {
+ t.Errorf("Platform.Vendor: got %q, want %q", p.Platform.Vendor, "dbt Labs")
+ }
+ if p.OSSIEPluginSpec != "0.1.0" {
+ t.Errorf("OSSIEPluginSpec: got %q, want %q", p.OSSIEPluginSpec, "0.1.0")
+ }
+ if p.OSSIESpecVersion != ">=0.2.0" {
+ t.Errorf("OSSIESpecVersion: got %q, want %q", p.OSSIESpecVersion, ">=0.2.0")
+ }
+ wantInvoke := []string{"ossie-plugin-dbt", "to-osi"}
+ if !equalStringSlice(p.Convert.ToOSI.Invoke, wantInvoke) {
+ t.Errorf("ToOSI.Invoke: got %v, want %v", p.Convert.ToOSI.Invoke, wantInvoke)
+ }
+ wantAccepts := []string{".yaml", ".json"}
+ if !equalStringSlice(p.Convert.ToOSI.Accepts, wantAccepts) {
+ t.Errorf("ToOSI.Accepts: got %v, want %v", p.Convert.ToOSI.Accepts, wantAccepts)
+ }
+ wantFromInvoke := []string{"ossie-plugin-dbt", "from-osi"}
+ if !equalStringSlice(p.Convert.FromOSI.Invoke, wantFromInvoke) {
+ t.Errorf("FromOSI.Invoke: got %v, want %v", p.Convert.FromOSI.Invoke, wantFromInvoke)
+ }
+ if stderr.Len() != 0 {
+ t.Errorf("unexpected warning: %q", stderr.String())
+ }
+}
+
+func TestDiscover_malformedYAML(t *testing.T) {
+ root := t.TempDir()
+ writePlugin(t, root, "bad", "this: is: not: valid: yaml: ][")
+ var stderr strings.Builder
+
+ plugins, err := plugin.Discover(root, &stderr)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(plugins) != 0 {
+ t.Errorf("expected 0 plugins, got %d", len(plugins))
+ }
+ if !strings.Contains(stderr.String(), filepath.Join(root, "bad")) {
+ t.Errorf("warning should contain plugin path, got: %q", stderr.String())
+ }
+}
+
+func TestDiscover_missingRequiredField(t *testing.T) {
+ root := t.TempDir()
+ writePlugin(t, root, "noplat", `
+ossie_plugin_spec: "0.1.0"
+ossie_spec_version: ">=0.2.0"
+platform:
+ vendor: dbt Labs
+convert:
+ to_osi:
+ invoke: ["bin/convert"]
+ accepts: [".yaml"]
+ from_osi:
+ invoke: ["bin/convert"]
+`)
+ var stderr strings.Builder
+
+ plugins, err := plugin.Discover(root, &stderr)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(plugins) != 0 {
+ t.Errorf("expected 0 plugins, got %d", len(plugins))
+ }
+ if !strings.Contains(stderr.String(), filepath.Join(root, "noplat")) {
+ t.Errorf("warning should contain plugin path, got: %q", stderr.String())
+ }
+}
+
+func TestDiscover_missingPluginYAML(t *testing.T) {
+ root := t.TempDir()
+ // Create a directory with no plugin.yaml inside it.
+ if err := os.MkdirAll(filepath.Join(root, "empty-plugin"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ var stderr strings.Builder
+
+ plugins, err := plugin.Discover(root, &stderr)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(plugins) != 0 {
+ t.Errorf("expected 0 plugins, got %d", len(plugins))
+ }
+ if !strings.Contains(stderr.String(), filepath.Join(root, "empty-plugin")) {
+ t.Errorf("warning should contain plugin path, got: %q", stderr.String())
+ }
+}
+
+func TestDiscover_multipleMixed(t *testing.T) {
+ root := t.TempDir()
+ writePlugin(t, root, "valid", validPluginYAML)
+ writePlugin(t, root, "malformed", "bad: yaml: ][")
+ if err := os.MkdirAll(filepath.Join(root, "no-yaml"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ var stderr strings.Builder
+
+ plugins, err := plugin.Discover(root, &stderr)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(plugins) != 1 {
+ t.Errorf("expected 1 plugin, got %d", len(plugins))
+ }
+ warnings := stderr.String()
+ if !strings.Contains(warnings, filepath.Join(root, "malformed")) {
+ t.Errorf("expected warning for malformed, got: %q", warnings)
+ }
+ if !strings.Contains(warnings, filepath.Join(root, "no-yaml")) {
+ t.Errorf("expected warning for no-yaml, got: %q", warnings)
+ }
+}
+
+func TestDiscover_ossieKeyPreferred(t *testing.T) {
+ root := t.TempDir()
+ writePlugin(t, root, "both-keys", `
+ossie_plugin_spec: "ossie-value"
+osi_plugin_spec: "osi-value"
+ossie_spec_version: ">=1.0.0"
+osi_spec_version: ">=0.1.0"
+platform:
+ name: test
+convert:
+ to_osi:
+ invoke: ["bin/convert"]
+ accepts: [".yaml"]
+ from_osi:
+ invoke: ["bin/convert"]
+`)
+ var stderr strings.Builder
+
+ plugins, err := plugin.Discover(root, &stderr)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(plugins) != 1 {
+ t.Fatalf("expected 1 plugin, got %d", len(plugins))
+ }
+ if plugins[0].OSSIEPluginSpec != "ossie-value" {
+ t.Errorf("OSSIEPluginSpec: got %q, want %q", plugins[0].OSSIEPluginSpec, "ossie-value")
+ }
+ if plugins[0].OSSIESpecVersion != ">=1.0.0" {
+ t.Errorf("OSSIESpecVersion: got %q, want %q", plugins[0].OSSIESpecVersion, ">=1.0.0")
+ }
+}
+
+func TestDiscover_osiKeyFallback(t *testing.T) {
+ root := t.TempDir()
+ writePlugin(t, root, "osi-only", `
+osi_plugin_spec: "0.2.0"
+osi_spec_version: ">=0.1.0"
+platform:
+ name: legacy
+convert:
+ to_osi:
+ invoke: ["bin/convert"]
+ accepts: [".json"]
+ from_osi:
+ invoke: ["bin/convert"]
+`)
+ var stderr strings.Builder
+
+ plugins, err := plugin.Discover(root, &stderr)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(plugins) != 1 {
+ t.Fatalf("expected 1 plugin, got %d", len(plugins))
+ }
+ if plugins[0].OSSIEPluginSpec != "0.2.0" {
+ t.Errorf("OSSIEPluginSpec: got %q, want %q", plugins[0].OSSIEPluginSpec, "0.2.0")
+ }
+ if plugins[0].OSSIESpecVersion != ">=0.1.0" {
+ t.Errorf("OSSIESpecVersion: got %q, want %q", plugins[0].OSSIESpecVersion, ">=0.1.0")
+ }
+}
+
+func TestDiscover_strayFileIgnored(t *testing.T) {
+ root := t.TempDir()
+ writePlugin(t, root, "valid", validPluginYAML)
+ // Place a plain file alongside the plugin directory.
+ if err := os.WriteFile(filepath.Join(root, "stray.txt"), []byte("not a plugin"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ var stderr strings.Builder
+
+ plugins, err := plugin.Discover(root, &stderr)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(plugins) != 1 {
+ t.Errorf("expected 1 plugin, got %d", len(plugins))
+ }
+ if stderr.Len() != 0 {
+ t.Errorf("expected no warnings for stray file, got: %q", stderr.String())
+ }
+}
+
+// equalStringSlice returns true if a and b contain the same elements in the same order.
+func equalStringSlice(a, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
diff --git a/cli/internal/plugin/plugin.go b/cli/internal/plugin/plugin.go
new file mode 100644
index 0000000..00fa5a1
--- /dev/null
+++ b/cli/internal/plugin/plugin.go
@@ -0,0 +1,123 @@
+package plugin
+
+import "errors"
+
+// Plugin is the parsed and validated representation of a single installed plugin.
+// Path is the absolute path to the plugin's installation directory on disk.
+type Plugin struct {
+ Path string // absolute path to plugin dir on disk
+ OSSIEPluginSpec string
+ OSSIESpecVersion string
+ Platform Platform
+ Setup string // relative path to setup script; empty = no setup
+ Convert ConvertConfig
+}
+
+// Platform identifies the semantic platform this plugin handles.
+type Platform struct {
+ Name string // required; matched against --from/--to values
+ Vendor string // optional; human-readable
+}
+
+// ConvertConfig holds both conversion directions.
+type ConvertConfig struct {
+ ToOSI Direction
+ FromOSI Direction
+}
+
+// Direction describes one conversion direction.
+type Direction struct {
+ Invoke []string // command + args; first element is the executable
+ Accepts []string // file extensions e.g. [".yaml"]; populated on ToOSI only
+}
+
+// rawPlugin mirrors the on-disk plugin.yaml layout for YAML unmarshaling.
+//
+// ossie_* keys are the preferred (current brand) names. osi_* keys are
+// deprecated aliases kept for backward compatibility with plugins written
+// before the OSI → OSSIE rename. When both are present, ossie_* wins.
+// TODO: remove osi_* fallback support in a future major version.
+type rawPlugin struct {
+ OSSIEPluginSpec string `yaml:"ossie_plugin_spec"`
+ OSSIESpecVersion string `yaml:"ossie_spec_version"`
+ OSIPluginSpec string `yaml:"osi_plugin_spec"` // deprecated
+ OSISpecVersion string `yaml:"osi_spec_version"` // deprecated
+
+ Platform struct {
+ Name string `yaml:"name"`
+ Vendor string `yaml:"vendor"`
+ } `yaml:"platform"`
+
+ Setup string `yaml:"setup"`
+
+ Convert struct {
+ ToOSI struct {
+ Invoke []string `yaml:"invoke"`
+ Accepts []string `yaml:"accepts"`
+ } `yaml:"to_osi"`
+ FromOSI struct {
+ Invoke []string `yaml:"invoke"`
+ } `yaml:"from_osi"`
+ } `yaml:"convert"`
+}
+
+// validate checks that all required fields are present.
+// It performs presence checks only — not format validation.
+func (r *rawPlugin) validate() error {
+ pluginSpec := r.OSSIEPluginSpec
+ if pluginSpec == "" {
+ pluginSpec = r.OSIPluginSpec
+ }
+ specVersion := r.OSSIESpecVersion
+ if specVersion == "" {
+ specVersion = r.OSISpecVersion
+ }
+
+ switch {
+ case pluginSpec == "":
+ return errors.New("missing required field: ossie_plugin_spec (or osi_plugin_spec)")
+ case specVersion == "":
+ return errors.New("missing required field: ossie_spec_version (or osi_spec_version)")
+ case r.Platform.Name == "":
+ return errors.New("missing required field: platform.name")
+ case len(r.Convert.ToOSI.Invoke) == 0:
+ return errors.New("missing required field: convert.to_osi.invoke")
+ case len(r.Convert.ToOSI.Accepts) == 0:
+ return errors.New("missing required field: convert.to_osi.accepts")
+ case len(r.Convert.FromOSI.Invoke) == 0:
+ return errors.New("missing required field: convert.from_osi.invoke")
+ }
+ return nil
+}
+
+// toPlugin maps a validated rawPlugin to the exported Plugin type.
+// path is the absolute directory path of the plugin's installation.
+func (r *rawPlugin) toPlugin(path string) *Plugin {
+ pluginSpec := r.OSSIEPluginSpec
+ if pluginSpec == "" {
+ pluginSpec = r.OSIPluginSpec
+ }
+ specVersion := r.OSSIESpecVersion
+ if specVersion == "" {
+ specVersion = r.OSISpecVersion
+ }
+ return &Plugin{
+ Path: path,
+ OSSIEPluginSpec: pluginSpec,
+ OSSIESpecVersion: specVersion,
+ Platform: Platform{
+ Name: r.Platform.Name,
+ Vendor: r.Platform.Vendor,
+ },
+ Setup: r.Setup,
+ Convert: ConvertConfig{
+ ToOSI: Direction{
+ Invoke: r.Convert.ToOSI.Invoke,
+ Accepts: r.Convert.ToOSI.Accepts,
+ },
+ FromOSI: Direction{
+ Invoke: r.Convert.FromOSI.Invoke,
+ },
+ },
+ }
+}