Updates to fnctl to make UX better (#272)

* See the hello/go README for how this all works now.

* Node support for fnctl auto build

* Updated based on PR comments.
This commit is contained in:
Travis Reeder
2016-11-14 10:10:29 -08:00
committed by GitHub
parent 28d57e50a4
commit 3357476583
24 changed files with 402 additions and 185 deletions

1
fnctl/.gitignore vendored
View File

@@ -1,2 +1,3 @@
fnctl
vendor/
/fnctl.exe

View File

@@ -8,7 +8,7 @@ docker: vendor
docker push iron/fnctl
vendor:
glide install
glide install -v
test:
go test -v $(shell glide nv)

View File

@@ -1,22 +1,17 @@
# IronFunctions CLI
## Build
## Init
Ensure you have Go configured and installed in your environment. Once it is
done, run:
usage: fnctl init [--runtime node] [--entrypoint "node hello.js"] <name>
```sh
$ make
```
Init will help you create a function.yaml file for the current directory.
It will build fnctl compatible with your local environment. You can test this
CLI, right away with:
```sh
$ ./fnctl
```
If there's a Dockerfile found, this will generate the basic file with just the image name.
It will then try to decipher the runtime based on the files in the current directory, if it can't figure it out, it will ask.
It will then take a best guess for what the entrypoint will be based on the language, it it can't guess, it will ask.
## Basic
You can operate IronFunctions from the command line.
```sh
@@ -47,7 +42,7 @@ $ fnctl routes delete otherapp hello # delete route
## Changing target host
`fnctl` is configured by default to talk to a locally installed IronFunctions.
`fnctl` is configured by default to talk http://localhost:8080.
You may reconfigure it to talk to a remote installation by updating a local
environment variable (`$API_URL`):
```sh
@@ -240,3 +235,19 @@ environment variables prefixed with `CONFIG_`.
Repeated calls to `fnctl route create` will trigger an update of the given
route, thus you will be able to change any of these attributes later in time
if necessary.
## Build
Ensure you have Go configured and installed in your environment. Once it is
done, run:
```sh
$ make
```
It will build fnctl compatible with your local environment. You can test this
CLI, right away with:
```sh
$ ./fnctl
```

View File

@@ -36,6 +36,10 @@ func (b *buildcmd) walker(path string, info os.FileInfo, err error, w io.Writer)
// build will take the found valid function and build it
func (b *buildcmd) build(path string) error {
fmt.Fprintln(b.verbwriter, "building", path)
_, err := b.buildfunc(path)
return err
ff, err := b.buildfunc(path)
if err != nil {
return err
}
fmt.Printf("Function %v built successfully.\n", ff.FullName())
return nil
}

View File

@@ -50,16 +50,8 @@ func (b *bumpcmd) bump(path string) error {
}
if funcfile.Version == "" {
img, ver := imageversion(funcfile.Name)
if ver == "" {
return nil
}
funcfile.Name = img
funcfile.Version = ver
} else if funcfile.Version != "" && strings.Contains(funcfile.Name, ":") {
return fmt.Errorf("cannot do version bump: this function has tag in its image name and version at same time. name: %s. version: %s", funcfile.Name, funcfile.Version)
funcfile.Version = initialVersion
}
s, err := storage.NewVersionStorage("local", funcfile.Version)
if err != nil {
return err
@@ -73,7 +65,12 @@ func (b *bumpcmd) bump(path string) error {
funcfile.Version = newver.String()
return storefuncfile(path, funcfile)
err = storefuncfile(path, funcfile)
if err != nil {
return err
}
fmt.Println("Bumped to version", funcfile.Version)
return nil
}
func imageversion(image string) (name, ver string) {

View File

@@ -1,6 +1,7 @@
package main
import (
"bytes"
"errors"
"fmt"
"io"
@@ -13,12 +14,13 @@ import (
"text/template"
"time"
"github.com/iron-io/functions/fnctl/langs"
"github.com/urfave/cli"
)
var errDockerFileNotFound = errors.New("no Dockerfile found for this function")
func isvalid(path string, info os.FileInfo) bool {
func isFuncfile(path string, info os.FileInfo) bool {
if info.IsDir() {
return false
}
@@ -38,7 +40,7 @@ func walker(path string, info os.FileInfo, err error, w io.Writer, f func(path s
if err := f(path); err != nil {
fmt.Fprintln(w, err)
} else {
fmt.Fprintln(w, "done")
// fmt.Fprintln(w, "done")
}
}
@@ -87,15 +89,15 @@ func (c *commoncmd) scan(walker func(path string, info os.FileInfo, err error, w
var walked bool
w := tabwriter.NewWriter(os.Stdout, 0, 8, 1, ' ', 0)
fmt.Fprint(w, "path", "\t", "result", "\n")
// fmt.Fprint(w, "path", "\t", "result", "\n")
err := filepath.Walk(c.wd, func(path string, info os.FileInfo, err error) error {
// fmt.Println("walking", info.Name())
if !c.recursively && path != c.wd && info.IsDir() {
return filepath.SkipDir
}
if !isvalid(path, info) {
if !isFuncfile(path, info) {
return nil
}
@@ -114,7 +116,7 @@ func (c *commoncmd) scan(walker func(path string, info os.FileInfo, err error, w
}
if !walked {
fmt.Println("all functions are up-to-date.")
fmt.Println("No function file found.")
return
}
@@ -184,26 +186,52 @@ func (c commoncmd) localbuild(path string, steps []string) error {
func (c commoncmd) dockerbuild(path string, ff *funcfile) error {
dir := filepath.Dir(path)
var helper langs.LangHelper
dockerfile := filepath.Join(dir, "Dockerfile")
if _, err := os.Stat(dockerfile); os.IsNotExist(err) {
if !exists(dockerfile) {
err := writeTmpDockerfile(dir, ff)
defer os.Remove(filepath.Join(dir, "Dockerfile"))
if err != nil {
return err
}
helper, err = langs.GetLangHelper(*ff.Runtime)
if err != nil {
return err
}
if helper.HasPreBuild() {
err := helper.PreBuild()
if err != nil {
return err
}
}
}
fmt.Printf("Building image %v\n", ff.FullName())
cmd := exec.Command("docker", "build", "-t", ff.FullName(), ".")
cmd.Dir = dir
cmd.Stderr = c.verbwriter
cmd.Stdout = c.verbwriter
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
if err := cmd.Run(); err != nil {
return fmt.Errorf("error running docker build: %v", err)
}
if helper != nil {
err := helper.AfterBuild()
if err != nil {
return err
}
}
return nil
}
func exists(name string) bool {
if _, err := os.Stat(name); err != nil {
if os.IsNotExist(err) {
return false
}
}
return true
}
var acceptableFnRuntimes = map[string]string{
"elixir": "iron/elixir",
"erlang": "iron/erlang",
@@ -221,10 +249,9 @@ var acceptableFnRuntimes = map[string]string{
}
const tplDockerfile = `FROM {{ .BaseImage }}
ADD ./ /
ENTRYPOINT ["{{ .Entrypoint }}"]
WORKDIR /function
ADD . /function/
ENTRYPOINT [{{ .Entrypoint }}]
`
func writeTmpDockerfile(dir string, ff *funcfile) error {
@@ -247,10 +274,23 @@ func writeTmpDockerfile(dir string, ff *funcfile) error {
return err
}
// convert entrypoint string to slice
epvals := strings.Fields(*ff.Entrypoint)
var buffer bytes.Buffer
for i, s := range epvals {
if i > 0 {
buffer.WriteString(", ")
}
buffer.WriteString("\"")
buffer.WriteString(s)
buffer.WriteString("\"")
}
fmt.Println(buffer.String())
t := template.Must(template.New("Dockerfile").Parse(tplDockerfile))
err = t.Execute(fd, struct {
BaseImage, Entrypoint string
}{rt, *ff.Entrypoint})
}{rt, buffer.String()})
fd.Close()
return err
}

13
fnctl/errors.go Normal file
View File

@@ -0,0 +1,13 @@
package main
type NotFoundError struct {
S string
}
func (e *NotFoundError) Error() string {
return e.S
}
func newNotFoundError(s string) *NotFoundError {
return &NotFoundError{S: s}
}

View File

@@ -14,15 +14,9 @@ import (
var (
validfn = [...]string{
"functions.yaml",
"functions.yml",
"function.yaml",
"function.yml",
"fn.yaml",
"fn.yml",
"functions.json",
"function.json",
"fn.json",
}
errUnexpectedFileFormat = errors.New("unexpected file format for function file")
@@ -63,6 +57,15 @@ func (ff *funcfile) RuntimeTag() (runtime, tag string) {
return rt[:tagpos], rt[tagpos+1:]
}
func findFuncfile() (*funcfile, error) {
for _, fn := range validfn {
if exists(fn) {
return parsefuncfile(fn)
}
}
return nil, newNotFoundError("could not find function file")
}
func parsefuncfile(path string) (*funcfile, error) {
ext := filepath.Ext(path)
switch ext {

View File

@@ -1,13 +1,24 @@
package main
/*
usage: fnctl init <name>
If there's a Dockerfile found, this will generate the basic file with just the image name. exit
It will then try to decipher the runtime based on the files in the current directory, if it can't figure it out, it will ask.
It will then take a best guess for what the entrypoint will be based on the language, it it can't guess, it will ask.
*/
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/iron-io/functions/fnctl/langs"
"github.com/urfave/cli"
)
@@ -27,6 +38,7 @@ var (
".pl": "perl",
".py": "python",
".scala": "scala",
".rb": "ruby",
}
fnRuntimes []string
@@ -39,8 +51,10 @@ func init() {
}
type initFnCmd struct {
force bool
runtime string
name string
force bool
runtime *string
entrypoint *string
}
func initFn() cli.Command {
@@ -49,19 +63,24 @@ func initFn() cli.Command {
return cli.Command{
Name: "init",
Usage: "create a local function.yaml file",
Description: "Entrypoint is the binary file which the container engine will invoke when the request comes in - equivalent to Dockerfile ENTRYPOINT.",
ArgsUsage: "<entrypoint>",
Description: "Creates a function.yaml file in the current directory. ",
ArgsUsage: "<DOCKERHUB_USERNAME:FUNCTION_NAME>",
Action: a.init,
Flags: []cli.Flag{
cli.BoolFlag{
Name: "f",
Name: "force, f",
Usage: "overwrite existing function.yaml",
Destination: &a.force,
},
cli.StringFlag{
Name: "runtime",
Usage: "choose an existing runtime - " + strings.Join(fnRuntimes, ", "),
Destination: &a.runtime,
Destination: a.runtime,
},
cli.StringFlag{
Name: "entrypoint",
Usage: "entrypoint is the command to run to start this function - equivalent to Dockerfile ENTRYPOINT.",
Destination: a.entrypoint,
},
},
}
@@ -69,57 +88,125 @@ func initFn() cli.Command {
func (a *initFnCmd) init(c *cli.Context) error {
if !a.force {
for _, fn := range validfn {
if _, err := os.Stat(fn); !os.IsNotExist(err) {
return errors.New("function file already exists")
ff, err := findFuncfile()
if err != nil {
if _, ok := err.(*NotFoundError); ok {
// great, we're about to make one
} else {
return err
}
}
if ff != nil {
return errors.New("function file already exists")
}
}
entrypoint := c.Args().First()
if entrypoint == "" {
fmt.Print("Please, specify an entrypoint for your function: ")
fmt.Scanln(&entrypoint)
}
if entrypoint == "" {
return errors.New("entrypoint is missing")
}
pwd, err := os.Getwd()
err := a.buildFuncFile(c)
if err != nil {
return fmt.Errorf("error detecting current working directory: %s\n", err)
return err
}
if a.runtime == "" {
rt, err := detectRuntime(pwd)
if err != nil {
return err
}
var ok bool
a.runtime, ok = fileExtToRuntime[rt]
if !ok {
return fmt.Errorf("could not detect language of this function: %s\n", a.runtime)
}
}
if _, ok := acceptableFnRuntimes[a.runtime]; !ok {
return fmt.Errorf("cannot use runtime %s", a.runtime)
}
/*
Now we can make some guesses for the entrypoint based on runtime.
If Go, use ./foldername, if ruby, use ruby and a filename. If node, node + filename
*/
ff := &funcfile{
Runtime: &a.runtime,
Name: a.name,
Runtime: a.runtime,
Version: initialVersion,
Entrypoint: &entrypoint,
Entrypoint: a.entrypoint,
}
if err := encodeFuncfileYAML("function.yaml", ff); err != nil {
return err
}
fmt.Println("function.yaml written")
fmt.Println("function.yaml created.")
return nil
}
func detectRuntime(path string) (string, error) {
func (a *initFnCmd) buildFuncFile(c *cli.Context) error {
pwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("error detecting current working directory: %s\n", err)
}
a.name = c.Args().First()
if a.name == "" {
// todo: also check that it's valid image name format
return errors.New("Please specify a name for your function in the following format <DOCKERHUB_USERNAME>:<FUNCTION_NAME>")
}
if exists("Dockerfile") {
// then don't need anything else
fmt.Println("Dockerfile found, will use that to build.")
return nil
}
var rt string
var filename string
if a.runtime == nil || *a.runtime == "" {
filename, rt, err = detectRuntime(pwd)
if err != nil {
return err
}
a.runtime = &rt
fmt.Printf("assuming %v runtime\n", rt)
}
if _, ok := acceptableFnRuntimes[*a.runtime]; !ok {
return fmt.Errorf("init does not support the %s runtime, you'll have to create your own Dockerfile for this function", *a.runtime)
}
if a.entrypoint == nil || *a.entrypoint == "" {
ep, err := detectEntrypoint(filename, *a.runtime, pwd)
if err != nil {
return fmt.Errorf("could not detect entrypoint for %v, use --entrypoint to add it explicitly. %v", *a.runtime, err)
}
a.entrypoint = &ep
}
return nil
}
// detectRuntime this looks at the files in the directory and if it finds a support file extension, it
// returns the filename and runtime for that extension.
func detectRuntime(path string) (filename string, runtime string, err error) {
err = filepath.Walk(path, func(_ string, info os.FileInfo, _ error) error {
if info.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(info.Name()))
if ext == "" {
return nil
}
var ok bool
runtime, ok = fileExtToRuntime[ext]
if ok {
// first match, exiting - http://stackoverflow.com/a/36713726/105562
filename = info.Name()
return io.EOF
}
return nil
})
if err != nil {
if err == io.EOF {
return filename, runtime, nil
}
return "", "", fmt.Errorf("file walk error: %s\n", err)
}
return "", "", fmt.Errorf("no supported files found to guess runtime, please set runtime explicitly with --runtime flag")
}
func detectEntrypoint(filename, runtime, pwd string) (string, error) {
helper, err := langs.GetLangHelper(runtime)
if err != nil {
return "", err
}
return helper.Entrypoint(filename)
}
func scoreExtension(path string) (string, error) {
scores := map[string]uint{
"": 0,
}
@@ -145,7 +232,6 @@ func detectRuntime(path string) (string, error) {
biggest = ext
}
}
return biggest, nil
}

21
fnctl/langs/base.go Normal file
View File

@@ -0,0 +1,21 @@
package langs
import "fmt"
// GetLangHelper returns a LangHelper for the passed in language
func GetLangHelper(lang string) (LangHelper, error) {
switch lang {
case "go":
return &GoLangHelper{}, nil
case "node":
return &NodeLangHelper{}, nil
}
return nil, fmt.Errorf("No language helper found for %v", lang)
}
type LangHelper interface {
Entrypoint(filename string) (string, error)
HasPreBuild() bool
PreBuild() error
AfterBuild() error
}

48
fnctl/langs/go.go Normal file
View File

@@ -0,0 +1,48 @@
package langs
import (
"fmt"
"os"
"os/exec"
"strings"
)
type GoLangHelper struct {
}
func (lh *GoLangHelper) Entrypoint(filename string) (string, error) {
// uses a common binary name: func
// return fmt.Sprintf("./%v", filepath.Base(pwd)), nil
return "./func", nil
}
func (lh *GoLangHelper) HasPreBuild() bool {
return true
}
// PreBuild for Go builds the binary so the final image can be as small as possible
func (lh *GoLangHelper) PreBuild() error {
wd, err := os.Getwd()
if err != nil {
return err
}
// todo: this won't work if the function is more complex since the import paths won't match up, need to fix
pbcmd := fmt.Sprintf("docker run --rm -v %s:/go/src/github.com/x/y -w /go/src/github.com/x/y iron/go:dev go build -o func", wd)
fmt.Println("Running prebuild command:", pbcmd)
parts := strings.Fields(pbcmd)
head := parts[0]
parts = parts[1:len(parts)]
cmd := exec.Command(head, parts...)
// cmd.Dir = dir
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
if err := cmd.Run(); err != nil {
return fmt.Errorf("error running docker build: %v", err)
}
return nil
}
func (lh *GoLangHelper) AfterBuild() error {
return os.Remove("func")
}

23
fnctl/langs/node.go Normal file
View File

@@ -0,0 +1,23 @@
package langs
import "fmt"
type NodeLangHelper struct {
}
func (lh *NodeLangHelper) Entrypoint(filename string) (string, error) {
return fmt.Sprintf("node %v", filename), nil
}
func (lh *NodeLangHelper) HasPreBuild() bool {
return false
}
// PreBuild for Go builds the binary so the final image can be as small as possible
func (lh *NodeLangHelper) PreBuild() error {
return nil
}
func (lh *NodeLangHelper) AfterBuild() error {
return nil
}

View File

@@ -83,9 +83,10 @@ func (p *publishcmd) publish(path string) error {
}
func (p publishcmd) dockerpush(ff *funcfile) error {
fmt.Printf("Pushing function %v to Docker Hub.\n", ff.FullName())
cmd := exec.Command("docker", "push", ff.FullName())
cmd.Stderr = p.verbwriter
cmd.Stdout = p.verbwriter
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
if err := cmd.Run(); err != nil {
return fmt.Errorf("error running docker push: %v", err)
}

View File

@@ -20,7 +20,7 @@ func push() cli.Command {
flags = append(flags, cmd.commoncmd.flags()...)
return cli.Command{
Name: "push",
Usage: "scan local directory for functions and push them.",
Usage: "push function to Docker Hub",
Flags: flags,
Action: cmd.scan,
}
@@ -55,10 +55,5 @@ func (p *pushcmd) push(path string) error {
if err := p.dockerpush(funcfile); err != nil {
return err
}
if err := p.route(path, funcfile); err != nil {
return err
}
return nil
}

View File

@@ -36,7 +36,16 @@ func runflags() []cli.Flag {
func (r *runCmd) run(c *cli.Context) error {
image := c.Args().First()
if image == "" {
return errors.New("error: image name is missing")
// check for a funcfile
ff, err := findFuncfile()
if err != nil {
if _, ok := err.(*NotFoundError); ok {
return errors.New("error: image name is missing or no function file found")
} else {
return err
}
}
image = ff.FullName()
}
sh := []string{"docker", "run", "--rm", "-i"}
@@ -60,7 +69,24 @@ func (r *runCmd) run(c *cli.Context) error {
sh = append(sh, image)
cmd := exec.Command(sh[0], sh[1:]...)
cmd.Stdin = os.Stdin
// Check if stdin is being piped, and if not, create our own pipe with nothing in it
// http://stackoverflow.com/questions/22744443/check-if-there-is-something-to-read-on-stdin-in-golang
stat, err := os.Stdin.Stat()
if err != nil {
// On Windows, this gets an error if nothing is piped in.
// If something is piped in, it works fine.
// Turns out, this works just fine in our case as the piped stuff works properly and the non-piped doesn't hang either.
// See: https://github.com/golang/go/issues/14853#issuecomment-260170423
// log.Println("Warning: couldn't stat stdin, you are probably on Windows. Be sure to pipe something into this command, eg: 'echo \"hello\" | fnctl run'")
} else {
if (stat.Mode() & os.ModeCharDevice) == 0 {
// log.Println("data is being piped to stdin")
cmd.Stdin = os.Stdin
} else {
// log.Println("stdin is from a terminal")
cmd.Stdin = strings.NewReader("")
}
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = env