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,
+			},
+		},
+	}
+}