Extensible Fission CLI (#743)

This PR introduces extensibility to the Fission CLI. The principle behind the design of this extensibility is taken from the approach git uses: the main binary (fission) calls other binaries (fission-workflows) - which it discovers using simple prefix-rules - based on specific commands (fission workflow <command>).
This commit is contained in:
Erwin van Eyk
2018-08-17 15:20:46 +08:00
committed by Ta-Ching Chen
parent ad28922871
commit 5c1d8bc90c
7 changed files with 606 additions and 13 deletions
+114 -13
View File
@@ -20,12 +20,16 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/ghodss/yaml"
"github.com/urfave/cli"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/fission/fission"
"github.com/fission/fission/fission/log"
"github.com/fission/fission/fission/plugin"
"github.com/fission/fission/fission/portforward"
"github.com/urfave/cli"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func getFissionNamespace() string {
@@ -77,17 +81,15 @@ func main() {
app.Name = "fission"
app.Usage = "Serverless functions for Kubernetes"
app.Version = fission.Version
cli.VersionPrinter = func(c *cli.Context) {
clientVer := fission.BuildInfo().String()
fmt.Printf("Client Version: %v\n", clientVer)
serverInfo, err := getClient(getServerUrl()).ServerInfo()
if err != nil {
fmt.Printf("Error getting Fission API version: %v", err)
} else {
serverVer := serverInfo.Build.String()
fmt.Printf("Server Version: %v\n", serverVer)
cli.VersionPrinter = versionPrinter
app.CustomAppHelpTemplate = helpTemplate
app.ExtraInfo = func() map[string]string {
info := map[string]string{}
for _, pmd := range plugin.FindAll() {
names := strings.Join(append([]string{pmd.Name}, pmd.Aliases...), ", ")
info[names] = pmd.Usage
}
return info
}
app.Flags = []cli.Flag{
@@ -310,8 +312,107 @@ func main() {
{Name: "package", Aliases: []string{"pkg"}, Usage: "Manage packages", Subcommands: pkgSubCommands},
{Name: "spec", Aliases: []string{"specs"}, Usage: "Manage a declarative app specification", Subcommands: specSubCommands},
{Name: "upgrade", Aliases: []string{}, Usage: "Upgrade tool from fission v0.1", Subcommands: upgradeSubCommands},
cmdPlugin,
}
app.Before = cliHook
app.CommandNotFound = handleCommandNotFound
app.Run(os.Args)
}
func handleCommandNotFound(ctx *cli.Context, subCommand string) {
pmd, err := plugin.Find(subCommand)
if err != nil {
switch err {
case plugin.ErrPluginNotFound:
url, ok := plugin.SearchRegistries(subCommand)
if !ok {
log.Fatal("No help topic for '" + subCommand + "'")
}
log.Fatal(fmt.Sprintf(`Command '%v' is not installed.
It is available to download at '%v'.
To install it for your local Fission CLI:
1. Download the plugin binary for your OS from the URL
2. Ensure that the plugin binary is executable: chmod +x <binary>
2. Add the plugin binary to your $PATH: mv <binary> /usr/local/bin/fission-%v`, subCommand, url, subCommand))
default:
log.Fatal("Error occurred when invoking " + subCommand + ": " + err.Error())
}
os.Exit(1)
}
// Rebuild global arguments string (urfave/cli does not have an option to get the raw input of the global flags)
var globalArgs []string
for _, globalFlagName := range ctx.GlobalFlagNames() {
val := fmt.Sprintf("%v", ctx.GlobalGeneric(globalFlagName))
if len(val) > 0 {
globalArgs = append(globalArgs, fmt.Sprintf("--%v", globalFlagName), val)
}
}
args := append(globalArgs, ctx.Args().Tail()...)
err = plugin.Exec(pmd, args)
if err != nil {
os.Exit(1)
}
}
// Versions is a container of versions of the client (and its plugins) and server (and its plugins).
type Versions struct {
Client map[string]fission.BuildMeta `json:"client"`
Server map[string]fission.BuildMeta `json:"server"`
}
func versionPrinter(_ *cli.Context) {
serverInfo, err := getClient(getServerUrl()).ServerInfo()
if err != nil {
log.Warn(fmt.Sprintf("Error getting Fission API version: %v", err))
}
// Fetch client versions
versions := Versions{
Client: map[string]fission.BuildMeta{
"fission/core": fission.BuildInfo(),
},
}
for _, pmd := range plugin.FindAll() {
versions.Client[pmd.Name] = fission.BuildMeta{
Version: pmd.Version,
}
}
// Fetch server versions
versions.Server = map[string]fission.BuildMeta{
"fission/core": serverInfo.Build,
}
// FUTURE: fetch versions of plugins server-side
bs, err := yaml.Marshal(versions)
if err != nil {
log.Fatal("Failed to format versions: " + err.Error())
}
fmt.Print(string(bs))
}
var helpTemplate = `NAME:
{{.Name}}{{if .Usage}} - {{.Usage}}{{end}}
USAGE:
{{if .UsageText}}{{.UsageText}}{{else}}{{.HelpName}} {{if .VisibleFlags}}[global options]{{end}}{{if .Commands}} command [command options]{{end}} {{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}}{{end}}{{if .Version}}{{if not .HideVersion}}
VERSION:
{{.Version}}{{end}}{{end}}{{if .Description}}
DESCRIPTION:
{{.Description}}{{end}}{{if .VisibleCommands}}
COMMANDS:{{range .VisibleCategories}}{{if .Name}}
{{.Name}}:{{end}}{{range .VisibleCommands}}
{{join .Names ", "}}{{"\t"}}{{.Usage}}{{end}}{{end}}{{end}}{{if .VisibleFlags}}
PLUGIN COMMANDS:{{ range $name, $usage := ExtraInfo }}
{{$name}}{{"\t"}}{{$usage}}{{end}}
GLOBAL OPTIONS:
{{range $index, $option := .VisibleFlags}}{{if $index}}
{{end}}{{$option}}{{end}}{{end}}
`
+50
View File
@@ -0,0 +1,50 @@
/*
Copyright 2016 The Fission Authors.
Licensed 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.
*/
package main
import (
"fmt"
"os"
"text/tabwriter"
"github.com/urfave/cli"
"github.com/fission/fission/fission/plugin"
)
var cmdPlugin = cli.Command{
Name: "plugin",
Aliases: []string{"plugins"},
Usage: "Manage Fission CLI plugins",
Subcommands: []cli.Command{
{
Name: "list",
Usage: "List installed client plugins",
Action: pluginList,
},
},
}
func pluginList(_ *cli.Context) error {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', 0)
fmt.Fprintln(w, "NAME\tVERSION\tPATH")
for _, p := range plugin.FindAll() {
fmt.Fprintf(w, "%v\t%v\t%v\n", p.Name, p.Version, p.Path)
}
w.Flush()
return nil
}
+176
View File
@@ -0,0 +1,176 @@
/*
Copyright 2016 The Fission Authors.
Licensed 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.
*/
// Package plugins provides support for creating extensible CLIs
package plugin
import (
"bytes"
"context"
"encoding/json"
"errors"
"io/ioutil"
"os"
"os/exec"
"path"
"strings"
"time"
)
const (
cmdTimeout = 5 * time.Second
cmdMetadataArgs = "--plugin"
)
var (
ErrPluginNotFound = errors.New("plugin not found")
ErrPluginInvalid = errors.New("invalid plugin")
Prefix = "fission-"
)
// Metadata contains the metadata of a plugin.
// The only metadata that is guaranteed to be non-empty is the path and Name. All other fields are considered optional.
type Metadata struct {
Name string `json:"name,omitempty"`
Version string `json:"version,omitempty"`
Aliases []string `json:"aliases,omitempty"`
Usage string `json:"usage,omitempty"`
Path string `json:"path,omitempty"`
}
func (md *Metadata) AddAlias(alias string) {
if alias != md.Name && !md.HasAlias(alias) {
md.Aliases = append(md.Aliases, alias)
}
}
func (md *Metadata) HasAlias(needle string) bool {
for _, alias := range md.Aliases {
if alias == needle {
return true
}
}
return false
}
// Find searches the machine for the given plugin, returning the metadata of the plugin.
// The only metadata that is guaranteed to be non-empty is the path and Name. All other fields are considered optional.
// If found it returns the plugin, otherwise it returns ErrPluginNotFound if the plugin was not found.
func Find(pluginName string) (*Metadata, error) {
// Search PATH for plugin as command-name
// To check if plugin is actually there still.
pluginPath, err := findPluginOnPath(pluginName)
if err != nil {
// Fallback: Search for alias in each command
mds := FindAll()
for _, md := range mds {
if md.HasAlias(pluginName) {
return md, nil
}
}
return nil, ErrPluginNotFound
}
md, err := fetchPluginMetadata(pluginPath)
if err != nil {
return nil, err
}
return md, nil
}
// Exec executes the plugin using the provided args.
// All input and output is redirected to stdin, stdout, and stderr.
func Exec(md *Metadata, args []string) error {
cmd := exec.Command(md.Path, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// FindAll searches the machine for all plugins currently present.
func FindAll() map[string]*Metadata {
plugins := map[string]*Metadata{}
dirs := strings.Split(os.Getenv("PATH"), ":")
for _, dir := range dirs {
fs, err := ioutil.ReadDir(dir)
if err != nil {
continue
}
for _, f := range fs {
if !strings.HasPrefix(f.Name(), Prefix) {
continue
}
fp := path.Join(dir, f.Name())
md, err := fetchPluginMetadata(fp)
if err != nil {
continue
}
if existing, ok := plugins[md.Name]; ok {
for _, alias := range existing.Aliases {
md.AddAlias(alias)
}
}
plugins[md.Name] = md
}
}
return plugins
}
func findPluginOnPath(pluginName string) (path string, err error) {
binaryName := Prefix + pluginName
path, err = exec.LookPath(binaryName)
if err != nil || len(path) == 0 {
return "", ErrPluginNotFound
}
return path, nil
}
// fetchPluginMetadata attempts to fetch the plugin metadata given the plugin path.
func fetchPluginMetadata(pluginPath string) (*Metadata, error) {
d, err := os.Stat(pluginPath)
if err != nil {
return nil, ErrPluginNotFound
}
if m := d.Mode(); m.IsDir() || m&0111 == 0 {
return nil, ErrPluginInvalid
}
// Fetch the metadata from the plugin itself.
buf := bytes.NewBuffer(nil)
ctx, cancel := context.WithTimeout(context.Background(), cmdTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, pluginPath, cmdMetadataArgs) // Note: issue can occur with signal propagation
cmd.Stdout = buf
err = cmd.Run()
if err != nil {
return nil, err
}
// Parse metadata if possible
pluginName := strings.TrimPrefix(path.Base(pluginPath), Prefix)
md := &Metadata{}
err = json.Unmarshal(buf.Bytes(), md)
if err != nil {
md.Name = pluginName
}
md.Path = pluginPath
md.AddAlias(pluginName)
return md, nil
}
+104
View File
@@ -0,0 +1,104 @@
/*
Copyright 2016 The Fission Authors.
Licensed 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.
*/
package plugin
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestFind(t *testing.T) {
os.Clearenv()
testDir := path.Join(os.TempDir(), fmt.Sprintf("fission-test-plugins-%v", time.Now().UnixNano()))
err := os.MkdirAll(testDir, os.ModePerm)
if err != nil {
t.FailNow()
}
defer os.RemoveAll(testDir)
testBinary := path.Join(testDir, "foo")
md := &Metadata{
Name: "foo",
Version: "1.0.1",
Usage: "Usage help",
Aliases: []string{"bar"},
}
jsonMd, err := json.Marshal(md)
if err != nil {
t.FailNow()
}
err = ioutil.WriteFile(testBinary, []byte(fmt.Sprintf("#!/bin/sh\necho '%v'", string(jsonMd))), os.ModePerm)
if err != nil {
t.FailNow()
}
err = os.Setenv("PATH", testDir)
if err != nil {
t.FailNow()
}
Prefix = ""
found, err := Find(md.Name)
os.RemoveAll(testDir)
assert.NoError(t, err)
assert.NotEmpty(t, found)
assert.Equal(t, md.Name, found.Name)
assert.Equal(t, path.Join(testDir, md.Name), found.Path)
assert.Equal(t, md.Aliases, found.Aliases)
assert.Equal(t, md.Usage, found.Usage)
assert.Equal(t, md.Version, found.Version)
}
func TestExec(t *testing.T) {
os.Clearenv()
testDir := path.Join(os.TempDir(), fmt.Sprintf("fission-test-plugins-%v", time.Now().UnixNano()))
err := os.MkdirAll(testDir, os.ModePerm)
if err != nil {
t.FailNow()
}
defer os.RemoveAll(testDir)
testBinary := path.Join(testDir, "foo")
md := &Metadata{
Name: "foo",
Version: "1.0.1",
Usage: "Usage help",
Aliases: []string{"bar"},
Path: path.Join(testBinary),
}
jsonMd, err := json.Marshal(md)
if err != nil {
t.FailNow()
}
err = ioutil.WriteFile(testBinary, []byte(fmt.Sprintf("#!/bin/sh\necho '%v'", string(jsonMd))), os.ModePerm)
if err != nil {
t.FailNow()
}
err = os.Setenv("PATH", testDir)
if err != nil {
t.FailNow()
}
Prefix = ""
err = Exec(md, nil)
os.RemoveAll(testDir)
assert.NoError(t, err)
}
+29
View File
@@ -0,0 +1,29 @@
/*
Copyright 2016 The Fission Authors.
Licensed 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.
*/
package plugin
// builtinRegistry consists of a map of plugin names along with the relevant url.
var builtinRegistry = map[string]string{
"workflows": "https://github.com/fission/fission-workflows/releases",
}
// SearchRegistries will search (remote) registries for the presence of the command.
// For now we only use the builtinRegistry
func SearchRegistries(cmd string) (string, bool) {
url, ok := builtinRegistry[cmd]
return url, ok
}