diff --git a/go/cli/commands/build.go b/go/cli/commands/build.go index 48944e15c..10ff9dafc 100644 --- a/go/cli/commands/build.go +++ b/go/cli/commands/build.go @@ -15,14 +15,12 @@ package commands import ( - "bytes" "context" "embed" "errors" "fmt" "os" "os/exec" - "strings" "github.com/heroku/color" "github.com/spf13/cobra" @@ -30,80 +28,120 @@ import ( //go:embed embed/Dockerfile var f embed.FS +var execCmdFn = execCmd +var execLookPathFn = exec.LookPath const ( - ImageTag = "function:latest" + // Builder Type + Ko = "ko" + Docker = "docker" + + // Docker constant variables + Image = "function:latest" DockerfilePath = "Dockerfile" - builtinDockerfilePath = "embed/Dockerfile" + BuiltinDockerfilePath = "embed/Dockerfile" + + // Ko constant variables + KoDockerRepoEnvVar = "KO_DOCKER_REPO" + KoLocalRepo = "ko.local" ) -func NewBuildCmd(ctx context.Context) *cobra.Command { +func NewBuildRunner(ctx context.Context) *BuildRunner { r := &BuildRunner{ - ctx: ctx, + ctx: ctx, + Ko: &KoBuilder{}, + Docker: &DockerBuilder{}, } r.Command = &cobra.Command{ - Use: "build", - Short: "build the KRM function as a docker image", - PreRunE: r.PreRunE, - RunE: r.RunE, - } - r.Command.Flags().StringVarP(&r.Tag, "tag", "t", ImageTag, - "the docker image tag") - r.Command.Flags().StringVarP(&r.DockerfilePath, "file", "f", "", - "Name of the Dockerfile. If not given, using a default builtin Dockerfile") - return r.Command + Use: "build", + Short: "build your KRM function to a container image", + RunE: r.RunE, + } + r.Command.Flags().StringVarP(&r.BuilderType, "builder", "b", Ko, + "the image builder. `ko` is the default builder, which requires `go build`; `docker` is accepted, and "+ + " requires you to have docker installed and running") + r.Command.Flags().StringVarP(&r.Docker.Image, "image", "i", Image, + fmt.Sprintf("the image (with tag), default to %v", Image)) + r.Command.Flags().StringVarP(&r.Docker.DockerfilePath, "dockerfile", "f", "", + "path to the Dockerfile. If not given, using a default builtin Dockerfile") + r.Command.Flags().StringVarP(&r.Ko.Repo, "repo", "r", "", + "the image repo. default to ko.local") + r.Command.Flags().StringVarP(&r.Ko.Tag, "tag", "t", "latest", + "the ko image tag") + // TODO: Docker CLI uses `--tag` flag to refer to "image:tag", which could be confusing but broadly accepted. + // We should better guide users on how to use "tag" and "image" flags for kfn. + // Here we use "tag" for ko (same as `ko build --tag`) and "image" for docker (same as `docker build --tag`) + return r } type BuildRunner struct { ctx context.Context Command *cobra.Command - Tag string + BuilderType string + Tag string + Ko *KoBuilder + Docker *DockerBuilder +} + +type Builder interface { + Build() error + Validate() error +} + +type DockerBuilder struct { + Image string DockerfilePath string } -func (r *BuildRunner) PreRunE(cmd *cobra.Command, args []string) error { - if err := r.requireDocker(); err != nil { - return err - } - if !r.dockerfileExist() { - err := r.createDockerfile() - if err != nil { - return err - } - } - return nil +type KoBuilder struct { + Repo string + Tag string } func (r *BuildRunner) RunE(cmd *cobra.Command, args []string) error { - return r.runDockerBuild() + var builder Builder + switch r.BuilderType { + case Docker: + builder = r.Docker + case Ko: + builder = r.Ko + } + if err := builder.Validate(); err != nil { + return err + } + return builder.Build() } -func (r *BuildRunner) runDockerBuild() error { - args := []string{"build", ".", "-f", r.DockerfilePath, "--tag", r.Tag} - cmd := exec.Command("docker", args...) - var out, errout bytes.Buffer - cmd.Stdout = &out - cmd.Stderr = &errout - err := cmd.Run() +func (r *DockerBuilder) Build() error { + args := []string{"build", ".", "-f", r.DockerfilePath, "--tag", r.Image} + err := execCmdFn(nil, "docker", args...) if err != nil { - color.Red(strings.TrimSpace(errout.String())) return err } - color.Green(out.String()) - color.Green("Image %v builds successfully. Now you can publish the image", r.Tag) + color.Green("Image %v built successfully. Now you can publish the image", r.Image) return nil } -func (r *BuildRunner) requireDocker() error { - _, err := exec.LookPath("docker") +func (r *DockerBuilder) Validate() error { + if err := r.validateDockerInstalled(); err != nil { + return err + } + if r.dockerfileExists() { + return nil + } + return r.createDockerfile() +} + +func (r *DockerBuilder) validateDockerInstalled() error { + _, err := execLookPathFn("docker") if err != nil { return fmt.Errorf("kfn requires that `docker` is installed and on the PATH") } return nil } -func (r *BuildRunner) dockerfileExist() bool { +func (r *DockerBuilder) dockerfileExists() bool { if r.DockerfilePath == "" { r.DockerfilePath = DockerfilePath } @@ -114,14 +152,80 @@ func (r *BuildRunner) dockerfileExist() bool { return true } -func (r *BuildRunner) createDockerfile() error { - dockerfileContent, err := f.ReadFile(builtinDockerfilePath) +func (r *DockerBuilder) createDockerfile() error { + dockerfileContent, err := f.ReadFile(BuiltinDockerfilePath) if err != nil { return err } - if err := os.WriteFile(DockerfilePath, dockerfileContent, 0644); err != nil { + if err = os.WriteFile(DockerfilePath, dockerfileContent, 0644); err != nil { return err } - color.Green("created Dockerfile") + fmt.Println("created Dockerfile") return nil } + +func (r *KoBuilder) GuaranteeKoInstalled() error { + _, err := execLookPathFn("ko") + if err == nil { + return nil + } + gobin := os.Getenv("GOBIN") + if gobin == "" && os.Getenv("GOPATH") != "" { + gobin = os.Getenv("GOPATH") + "/bin" + } + if gobin == "" && os.Getenv("HOME") != "" { + gobin = os.Getenv("HOME") + "/go/bin" + } + var envs []string + if gobin != "" { + envs = []string{"GOBIN" + "=" + gobin} + } + if err = execCmdFn(envs, "go", "install", "github.com/google/ko@latest"); err != nil { + return err + } + fmt.Println("successfully installed ko") + return nil +} +func (r *KoBuilder) Build() error { + args := []string{"build", "-B", "--tags", r.Tag} + envs := []string{KoDockerRepoEnvVar + "=" + r.Repo} + err := execCmdFn(envs, "ko", args...) + if err != nil { + return err + } + + if r.Repo == KoLocalRepo { + color.Green("Image built successfully. Now you can publish the image") + } else { + color.Green("Image built and pushed successfully") + } + return nil +} + +func (r *KoBuilder) Validate() error { + if err := r.GuaranteeKoInstalled(); err != nil { + return err + } + // Find KO_DOCKER_REPO value from multiple places for `ko build`. + if r.Repo != "" { + return nil + } + if repo, ok := os.LookupEnv(KoDockerRepoEnvVar); ok { + r.Repo = repo + return nil + } + r.Repo = "ko.local" + return nil +} + +func execCmd(envs []string, name string, args ...string) error { + cmd := exec.Command(name, args...) + if len(envs) != 0 { + cmd.Env = os.Environ() + cmd.Env = append(cmd.Env, envs...) + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + return err +} diff --git a/go/cli/commands/build_test.go b/go/cli/commands/build_test.go new file mode 100644 index 000000000..71f0782c1 --- /dev/null +++ b/go/cli/commands/build_test.go @@ -0,0 +1,155 @@ +// Copyright 2022 Google LLC +// +// 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 commands + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuild(t *testing.T) { + testcases := map[string]struct { + args []string + // expected args is key, and expected return is value + cmdExpected []string + // expected env var is key, and existence is value + lookPathExpected map[string]bool + expectedError string + }{ + "default build is ko, ko already exists": { + args: []string{""}, + cmdExpected: []string{ + "KO_DOCKER_REPO=ko.local ko build -B --tags latest", + }, + lookPathExpected: map[string]bool{ + "ko": true, + }, + }, + "default build is ko, ko not exists": { + args: []string{""}, + cmdExpected: []string{ + "go install github.com/google/ko@latest", + "KO_DOCKER_REPO=ko.local ko build -B --tags latest", + }, + lookPathExpected: map[string]bool{ + "ko": false, + }, + }, + "ko as builder, specify repo": { + args: []string{"--repo=gcr.io/test"}, + cmdExpected: []string{ + "KO_DOCKER_REPO=gcr.io/test ko build -B --tags latest", + }, + lookPathExpected: map[string]bool{ + "ko": true, + }, + }, + "ko as builder, specify tag": { + args: []string{"--repo=gcr.io/test", "--tag=v1"}, + cmdExpected: []string{ + "KO_DOCKER_REPO=gcr.io/test ko build -B --tags v1", + }, + lookPathExpected: map[string]bool{ + "ko": true, + }, + }, + "docker as builder, docker not exists": { + args: []string{"--builder=docker"}, + lookPathExpected: map[string]bool{ + "docker": false, + }, + expectedError: "kfn requires that `docker` is installed and on the PATH", + }, + "docker as builder, docker exists": { + args: []string{"--builder=docker"}, + cmdExpected: []string{ + "docker build . -f Dockerfile --tag function:latest", + }, + lookPathExpected: map[string]bool{ + "docker": true, + }, + }, + "docker as builder, specify dockerfile": { + args: []string{"--builder=docker", "--dockerfile=tmp/Dockerfile"}, + cmdExpected: []string{ + "docker build . -f tmp/Dockerfile --tag function:latest", + }, + lookPathExpected: map[string]bool{ + "docker": true, + }, + }, + "docker as builder, specify image": { + args: []string{"--builder=docker", "--image=dockertest:latest", "--dockerfile=tmp/Dockerfile"}, + cmdExpected: []string{ + "docker build . -f tmp/Dockerfile --tag dockertest:latest", + }, + lookPathExpected: map[string]bool{ + "docker": true, + }, + }, + } + for name, test := range testcases { + r := NewBuildRunner(context.TODO()) + execCmdFn = func(envs []string, name string, args ...string) error { + fakeExecCmd(t, test.cmdExpected, envs, name, args...) + return nil + } + execLookPathFn = func(file string) (string, error) { + return "", fakeExecLookPath(t, test.lookPathExpected, file) + } + r.Command.SetArgs(test.args) + err := r.Command.Execute() + if test.expectedError == "" { + if err != nil { + t.Errorf("%v failed. got error: %v", name, err) + } + } else { + assert.EqualError(t, err, test.expectedError) + } + os.Remove("Dockerfile") + } +} + +func fakeExecCmd(t *testing.T, expectedArgsAndReturns []string, envs []string, name string, args ...string) { + var c []string + if name != "go" { + c = append(c, envs...) + } + c = append(c, name) + c = append(c, args...) + command := strings.Join(c, " ") + for _, expected := range expectedArgsAndReturns { + if expected == command { + return + } + } + t.Fatalf("unexpected command run %v", command) +} + +func fakeExecLookPath(t *testing.T, expectedlookPath map[string]bool, name string) error { + val, ok := expectedlookPath[name] + if !ok { + t.Fatalf("unexpected env var check %v", name) + } + if val { + return nil + } + return fmt.Errorf("env var not exists") +} diff --git a/go/cli/commands/init.go b/go/cli/commands/init.go index 71d9f7216..b89c6c015 100644 --- a/go/cli/commands/init.go +++ b/go/cli/commands/init.go @@ -29,7 +29,7 @@ const ( DefaultPkgName = "function" ) -func NewInitCmd(ctx context.Context) *cobra.Command { +func NewInitRunner(ctx context.Context) *InitRunner { r := &InitRunner{ ctx: ctx, } @@ -41,7 +41,7 @@ func NewInitCmd(ctx context.Context) *cobra.Command { } r.Command.Flags().StringVarP(&r.FnPkgPath, "fnPkg", "", DefaultFnPkg, "a kpt package that contains a basic KRM function source code to get start") - return r.Command + return r } // InitRunner initializes a KRM function project from a scaffolded `kpt pkg`. diff --git a/go/cli/go.mod b/go/cli/go.mod index 93e954d70..39d7a90a2 100644 --- a/go/cli/go.mod +++ b/go/cli/go.mod @@ -5,12 +5,16 @@ go 1.19 require ( github.com/heroku/color v0.0.6 github.com/spf13/cobra v1.6.1 + github.com/stretchr/testify v1.8.1 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.0.1 // indirect github.com/mattn/go-colorable v0.1.2 // indirect github.com/mattn/go-isatty v0.0.8 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go/cli/go.sum b/go/cli/go.sum index 6a367ac31..29d9662f7 100644 --- a/go/cli/go.sum +++ b/go/cli/go.sum @@ -1,4 +1,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/heroku/color v0.0.6 h1:UTFFMrmMLFcL3OweqP1lAdp8i1y/9oHqkeHjQ/b/Ny0= github.com/heroku/color v0.0.6/go.mod h1:ZBvOcx7cTF2QKOv4LbmoBtNl5uB17qWxGuzZrsi1wLU= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= @@ -7,13 +10,25 @@ github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +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/go/cli/main.go b/go/cli/main.go index a3b43a51d..6fd4dc53c 100644 --- a/go/cli/main.go +++ b/go/cli/main.go @@ -29,8 +29,8 @@ func main() { Short: "a CLI tool to edit your own KRM functions with ease", } ctx := context.Background() - cmd.AddCommand(commands.NewInitCmd(ctx)) - cmd.AddCommand(commands.NewBuildCmd(ctx)) + cmd.AddCommand(commands.NewInitRunner(ctx).Command) + cmd.AddCommand(commands.NewBuildRunner(ctx).Command) err = cmd.Execute() if err != nil { os.Exit(1)