Skip to content

Commit

Permalink
Support for CLI Extensions by OCM Plugins (#815)
Browse files Browse the repository at this point in the history
#### What this PR does / why we need it:

The OCM Plugin framework now supports two new features:
- definition of configuration types (consumed by the plugin)
- definition of CLI commands (for the OCM CLI)

Additionally it is possible to consume logging configuration from the
OCM CLI for all
plugin feature commands.

Examples see coding in `cmds/cliplugin`

#### Config Types

Config types are just registered at the Plugin Object;

```
	p := ppi.NewPlugin("cliplugin", version.Get().String())
        ...
	p.RegisterConfigType(configType)
```

The argument is just the config type as registered at the ocm library,
for example:

```
const ConfigType = "rhabarber.config.acme.org"

type Config struct {
	runtime.ObjectVersionedType `json:",inline"`
	...
}

func (a *Config) ApplyTo(ctx cfgcpi.Context, target interface{}) error {
        ...
}

func init() {
	configType = cfgcpi.NewConfigType[*Config](ConfigType, usage)
	cfgcpi.RegisterConfigType(configType)
}
```

#### CLI Commands

CLI commands are simple configured `cobra.Command` objects.
They are registered at the plugin object with

```
        cmd, err := clicmd.NewCLICommand(NewCommand(), clicmd.WithCLIConfig(), clicmd.WithVerb("check"))
	if err != nil {
		os.Exit(1)
	}
	p.RegisterCommand(NewCommand())
```

with coding similar to

```

type command struct {
	date string
}

func NewCommand() *cobra.Command {
	cmd := &command{}
	c := &cobra.Command{
		Use:   Name + " <options>",
		Short: "determine whether we are in rhubarb season",
		Long:  "The rhubarb season is between march and april.",
		RunE:  cmd.Run,
	}

	c.Flags().StringVarP(&cmd.date, "date", "d", "", "the date to ask for (MM/DD)")
	return c
}

func (c *command) Run(cmd *cobra.Command, args []string) error {
   ...
}
```

The plugin programming interface supports the generation of an extension
command directly from a
cobra command object using the method `NewCLICommand` from the
`ppi.clicmd` package.
Otherwise the `ppi.Command` interface can be implemented without
requiring a cobra command..

If the code wants to use the config framework, for example to
- use the OCM library again
- access credentials
- get configured with declared config types

the appropriate command feature must be set.
For the cobra support this is implemented by the option `WithCLIConfig`.
If set to true, the OCM CLI configuration is available for the config
context used in the
CLI code.

The command can be a top-level command or attached to a dedicated verb
(and optionally a realm like `ocm`or `oci`).
For the cobra support this can be requested by the option
`WithVerb(...)`.

If the config framework is used just add the following anonymoud import
for an automated configuration:

```
import (
        // enable mandelsoft plugin logging configuration.
	_ "github.com/open-component-model/ocm/pkg/contexts/ocm/plugin/ppi/config"
)
```

The plugin code is then configured with the configuration of the OCM CLI
and the config framework
can be used.
If the configuration should be handled by explicit plugin code a handler
can be registered with

```
func init() {
	command.RegisterCommandConfigHandler(yourHandler)
}
```

It gets a config yaml according to the config objects used by the OCM
library.

#### Logging

To get the logging configuration from the OCM CLI the plugin has be
configured with
 
```
	p.ForwardLogging()
```

If the standard mandelsoft logging from the OCM library is used the
configuration can
be implemented directly with an anonymous import of 

```
import (
        // enable mandelsoft plugin logging configuration.
	_ "github.com/open-component-model/ocm/pkg/contexts/ocm/plugin/ppi/logging"
)
```
The plugin code is then configured with the logging configuration of the
OCM CLI and the mandelsoft logging frame work
can be used.
If the logging configuration should be handled by explicit plugin code a
handler can be registered with

```
func init() {
	cmds.RegisterLoggingConfigHandler(yourHandler)
}
```

It gets a logging configuration yaml according to the logging config
used by the OCM library (`github.com/mandelsoft/logging/config`).

#### Using Plugin command extensions from the OCM library.

The plugin command extensions can also be called without the OCM CLI
directly from the OCM library.
Therefore the plugin objects provided by the library can be used.

Logging information and config information must explicitly be configured
to be passed to the
plugin. 

Therefore the context attribute `clicfgattr.Get(ctx)` is used. It can be
set via `clicfgattr.Set(...)`.
The logging configuration is extracted from the configured configuration
object with target type `*logging.LoggingConfiguration`.

If the code uses an OCM context configured with a
`(ocm)utils.ConfigureXXX` function, the cli config attribute is set
accordingly.

#### Which issue(s) this PR fixes:
<!--
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->

---------

Co-authored-by: Fabian Burth <[email protected]>
Co-authored-by: Hilmar Falkenberg <[email protected]>
  • Loading branch information
3 people authored Jun 25, 2024
1 parent 6a4ec88 commit 5de0297
Show file tree
Hide file tree
Showing 126 changed files with 3,978 additions and 708 deletions.
3 changes: 3 additions & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ linters:
- sqlclosecheck
- wastedassign

# Disabled because of deprecation
- execinquery

linters-settings:
gci:
sections:
Expand Down
11 changes: 10 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ COMMIT := $(shell git rev-parse --verify
CONTROLLER_TOOLS_VERSION ?= v0.14.0
CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen

PLATFORMS = windows/amd64 darwin/arm64 darwin/amd64 linux/amd64 linux/arm64

CREDS ?=
OCM := go run $(REPO_ROOT)/cmds/ocm $(CREDS)
CTF_TYPE ?= tgz
Expand All @@ -36,17 +38,24 @@ build: ${SOURCES}
CGO_ENABLED=0 go build -ldflags $(BUILD_FLAGS) -o bin/ocm ./cmds/ocm
CGO_ENABLED=0 go build -ldflags $(BUILD_FLAGS) -o bin/helminstaller ./cmds/helminstaller
CGO_ENABLED=0 go build -ldflags $(BUILD_FLAGS) -o bin/demo ./cmds/demoplugin
CGO_ENABLED=0 go build -ldflags $(BUILD_FLAGS) -o bin/cliplugin ./cmds/cliplugin
CGO_ENABLED=0 go build -ldflags $(BUILD_FLAGS) -o bin/ecrplugin ./cmds/ecrplugin


build-platforms: $(GEN)/.exists $(SOURCES)
@for i in $(PLATFORMS); do \
echo GOARCH=$$(basename $$i) GOOS=$$(dirname $$i); \
GOARCH=$$(basename $$i) GOOS=$$(dirname $$i) CGO_ENABLED=0 go build ./cmds/ocm ./cmds/helminstaller ./cmds/ecrplugin; \
done

.PHONY: install-requirements
install-requirements:
@make -C hack $@

.PHONY: prepare
prepare: generate format generate-deepcopy build test check

EFFECTIVE_DIRECTORIES := $(REPO_ROOT)/cmds/ocm/... $(REPO_ROOT)/cmds/helminstaller/... $(REPO_ROOT)/cmds/ecrplugin/... $(REPO_ROOT)/cmds/demoplugin/... $(REPO_ROOT)/examples/... $(REPO_ROOT)/pkg/...
EFFECTIVE_DIRECTORIES := $(REPO_ROOT)/cmds/ocm/... $(REPO_ROOT)/cmds/helminstaller/... $(REPO_ROOT)/cmds/ecrplugin/... $(REPO_ROOT)/cmds/demoplugin/... $(REPO_ROOT)/cmds/cliplugin/... $(REPO_ROOT)/examples/... $(REPO_ROOT)/cmds/subcmdplugin/... $(REPO_ROOT)/pkg/...

.PHONY: format
format:
Expand Down
110 changes: 110 additions & 0 deletions cmds/cliplugin/cmds/check/cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package check

import (
"fmt"
"strconv"
"strings"
"time"

// bind OCM configuration.
_ "github.com/open-component-model/ocm/pkg/contexts/ocm/plugin/ppi/config"

"github.com/mandelsoft/goutils/errors"
"github.com/mandelsoft/logging"
"github.com/spf13/cobra"

"github.com/open-component-model/ocm/pkg/contexts/ocm"
)

const Name = "check"

var log = logging.DynamicLogger(logging.DefaultContext(), logging.NewRealm("cliplugin/rhabarber"))

func New() *cobra.Command {
cmd := &command{}
c := &cobra.Command{
Use: Name + " <options>",
Short: "determine whether we are in rhubarb season",
Long: "The rhubarb season is between march and april.",
RunE: cmd.Run,
}

c.Flags().StringVarP(&cmd.date, "date", "d", "", "the date to ask for (MM/DD)")
return c
}

type command struct {
date string
}

var months = map[string]int{
"jan": 1,
"feb": 2,
"mär": 3, "mar": 3,
"apr": 4,
"mai": 5, "may": 5,
"jun": 6,
"jul": 7,
"aug": 8,
"sep": 9,
"okt": 10, "oct": 10,
"nov": 11,
"dez": 12, "dec": 12,
}

func (c *command) Run(cmd *cobra.Command, args []string) error {
season := Season{
Start: "mar/1",
End: "apr/30",
}

ctx := ocm.FromContext(cmd.Context())
ctx.ConfigContext().ApplyTo(0, &season)

start, err := ParseDate(season.Start)
if err != nil {
return errors.Wrapf(err, "invalid season start")
}

end, err := ParseDate(season.End)
if err != nil {
return errors.Wrapf(err, "invalid season end")
}
end = end.Add(time.Hour * 24)

d := time.Now()
if c.date != "" {
d, err = ParseDate(c.date)
if err != nil {
return err
}
}

log.Debug("testing rhabarb season", "date", d.String())
if d.After(start) && d.Before(end) {
fmt.Printf("Yeah, it's rhabarb season - happy rhabarbing!\n")
} else {
fmt.Printf("Sorry, but you have to stay hungry.\n")
}
return nil
}

func ParseDate(s string) (time.Time, error) {
parts := strings.Split(s, "/")
if len(parts) != 2 {
return time.Time{}, fmt.Errorf("invalid date, expected MM/DD")
}
month, err := strconv.Atoi(parts[0])
if err != nil {
month = months[strings.ToLower(parts[0])]
if month == 0 {
return time.Time{}, errors.Wrapf(err, "invalid month")
}
}
day, err := strconv.Atoi(parts[1])
if err != nil {
return time.Time{}, errors.Wrapf(err, "invalid day")
}

return time.Date(time.Now().Year(), time.Month(month), day, 0, 0, 0, 0, time.Local), nil //nolint:gosmopolitan // yes
}
70 changes: 70 additions & 0 deletions cmds/cliplugin/cmds/check/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package check

import (
cfgcpi "github.com/open-component-model/ocm/pkg/contexts/config/cpi"
"github.com/open-component-model/ocm/pkg/runtime"
)

const (
ConfigType = "rhabarber.config.acme.org"
ConfigTypeV1 = ConfigType + runtime.VersionSeparator + "v1"
)

var (
RhabarberType cfgcpi.ConfigType
RhabarberTypeV1 cfgcpi.ConfigType
)

func init() {
RhabarberType = cfgcpi.NewConfigType[*Config](ConfigType, usage)
cfgcpi.RegisterConfigType(RhabarberType)
RhabarberTypeV1 = cfgcpi.NewConfigType[*Config](ConfigTypeV1, "")

cfgcpi.RegisterConfigType(RhabarberTypeV1)
}

type Season struct {
Start string `json:"start"`
End string `json:"end"`
}

// Config describes a memory based repository interface.
type Config struct {
runtime.ObjectVersionedType `json:",inline"`
Season `json:",inline"`
}

// NewConfig creates a new memory ConfigSpec.
func NewConfig(start, end string) *Config {
return &Config{
ObjectVersionedType: runtime.NewVersionedTypedObject(ConfigType),
Season: Season{
Start: start,
End: end,
},
}
}

func (a *Config) GetType() string {
return ConfigType
}

func (a *Config) ApplyTo(ctx cfgcpi.Context, target interface{}) error {
t, ok := target.(*Season)
if !ok {
return cfgcpi.ErrNoContext(ConfigType)
}

*t = a.Season
return nil
}

const usage = `
The config type <code>` + ConfigType + `</code> can be used to configure the season for rhubarb:
<pre>
type: ` + ConfigType + `
start: mar/1
end: apr/30
</pre>
`
154 changes: 154 additions & 0 deletions cmds/cliplugin/cmds/cmd_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
//go:build unix

package cmds_test

import (
"bytes"

. "github.com/mandelsoft/goutils/testutils"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
. "github.com/open-component-model/ocm/cmds/ocm/testhelper"
. "github.com/open-component-model/ocm/pkg/contexts/ocm/plugin/testutils"

"github.com/mandelsoft/logging/logrusl"
"github.com/mandelsoft/logging/utils"

"github.com/open-component-model/ocm/pkg/contexts/ocm/attrs/plugincacheattr"
"github.com/open-component-model/ocm/pkg/version"
)

const KIND = "rhubarb"

var _ = Describe("cliplugin", func() {
Context("lib", func() {
var env *TestEnv
var plugins TempPluginDir

BeforeEach(func() {
env = NewTestEnv(TestData())
plugins = Must(ConfigureTestPlugins(env, "testdata/plugins"))

registry := plugincacheattr.Get(env)
// Expect(registration.RegisterExtensions(env)).To(Succeed())
p := registry.Get("cliplugin")
Expect(p).NotTo(BeNil())
Expect(p.Error()).To(Equal(""))
})

AfterEach(func() {
plugins.Cleanup()
env.Cleanup()
})

It("run plugin based ocm command", func() {
var buf bytes.Buffer

MustBeSuccessful(env.CatchOutput(&buf).Execute("--config", "testdata/config.yaml", "check", KIND, "-d", "jul/10"))

Expect("\n" + buf.String()).To(Equal(`
Yeah, it's rhabarb season - happy rhabarbing!
`))
})

It("runs plugin based ocm command with log", func() {
var stdout bytes.Buffer
var stdlog bytes.Buffer

lctx := env.OCMContext().LoggingContext()
lctx.SetBaseLogger(logrusl.WithWriter(utils.NewSyncWriter(&stdlog)).NewLogr())
MustBeSuccessful(env.CatchOutput(&stdout).
Execute("--config", "testdata/logcfg.yaml", "check", KIND, "-d", "jul/10"))

Expect("\n" + stdout.String()).To(Equal(`
Yeah, it's rhabarb season - happy rhabarbing!
`))
// {"date":".*","level":"debug","msg":"testing rhabarb season","realm":"cliplugin/rhabarber","time":".*"}
Expect(stdlog.String()).To(StringMatchTrimmedWithContext(`
[^ ]* debug \[cliplugin/rhabarber\] "testing rhabarb season" date="[^"]*"
`))
})

It("fails for undeclared config", func() {
var buf bytes.Buffer

Expect(env.CatchOutput(&buf).Execute("--config", "testdata/err.yaml", "check", KIND, "-d", "jul/10")).To(
MatchError(`config type "err.config.acme.org" is unknown`))
})

It("shows command help", func() {
var buf bytes.Buffer

MustBeSuccessful(env.CatchOutput(&buf).Execute("check", KIND, "--help"))
Expect(buf.String()).To(StringEqualTrimmedWithContext(`
ocm check rhubarb — Determine Whether We Are In Rhubarb Season
Synopsis:
ocm check rhubarb <options>
Flags:
-d, --date string the date to ask for (MM/DD)
-h, --help help for check
Description:
The rhubarb season is between march and april.
`))
})

It("shows command help from main command", func() {
var buf bytes.Buffer

MustBeSuccessful(env.CatchOutput(&buf).Execute("help", "check", KIND))
Expect(buf.String()).To(StringEqualTrimmedWithContext(`
ocm check rhubarb — Determine Whether We Are In Rhubarb Season
Synopsis:
ocm check rhubarb <options>
Flags:
-d, --date string the date to ask for (MM/DD)
-h, --help help for check
Description:
The rhubarb season is between march and april.
`))
})

It("describe", func() {
var buf bytes.Buffer

MustBeSuccessful(env.CatchOutput(&buf).Execute("describe", "plugin", "cliplugin"))
Expect(buf.String()).To(StringEqualTrimmedWithContext(`
Plugin Name: cliplugin
Plugin Version: ` + version.Get().String() + `
Path: ` + plugins.Path() + `/cliplugin
Status: valid
Source: manually installed
Capabilities: CLI Commands, Config Types
Description:
The plugin offers the check command for object type rhubarb to check the rhubarb season.
CLI Extensions:
- Name: check (determine whether we are in rhubarb season)
Object: rhubarb
Verb: check
Usage: check rhubarb <options>
The rhubarb season is between march and april.
Config Types for CLI Command Extensions:
- Name: rhabarber.config.acme.org
The config type «rhabarber.config.acme.org» can be used to configure the season for rhubarb:
type: rhabarber.config.acme.org
start: mar/1
end: apr/30
Versions:
- Version: v1
*** found 1 plugins
`))
})
})
})
13 changes: 13 additions & 0 deletions cmds/cliplugin/cmds/suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package cmds_test

import (
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

func TestConfig(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Demo CLI Plugin Command Test Suite")
}
3 changes: 3 additions & 0 deletions cmds/cliplugin/cmds/testdata/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
type: rhabarber.config.acme.org
start: jul/1
end: jul/31
Loading

0 comments on commit 5de0297

Please sign in to comment.