Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Execute External Build Command #996

Closed
wants to merge 8 commits into from
Closed
2 changes: 2 additions & 0 deletions contrib/completions/bash/s2i
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,8 @@ _s2i_build()
flags+=("--volume=")
two_word_flags+=("-v")
local_nonpersistent_flags+=("--volume=")
flags+=("--with-builder=")
local_nonpersistent_flags+=("--with-builder=")
flags+=("--ca=")
flags+=("--cert=")
flags+=("--key=")
Expand Down
2 changes: 2 additions & 0 deletions contrib/completions/zsh/s2i
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,8 @@ _s2i_build()
flags+=("--volume=")
two_word_flags+=("-v")
local_nonpersistent_flags+=("--volume=")
flags+=("--with-builder=")
local_nonpersistent_flags+=("--with-builder=")
flags+=("--ca=")
flags+=("--cert=")
flags+=("--key=")
Expand Down
4 changes: 4 additions & 0 deletions pkg/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@ type Config struct {

// AssembleRuntimeUser specifies the user to run the assemble-runtime script in container
AssembleRuntimeUser string

// WithBuilder specifies the external builder command, and trigger the behavior of building
// Dockerfile generated by `--as-dockerfile` with it.
WithBuilder string
}

// EnvironmentSpec specifies a single environment variable.
Expand Down
150 changes: 150 additions & 0 deletions pkg/build/strategies/external/external.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package external

import (
"bytes"
"fmt"
"os"
"os/exec"
"path"
"sort"
"strings"
"syscall"
"text/template"

"github.com/openshift/source-to-image/pkg/api"
"github.com/openshift/source-to-image/pkg/build/strategies/dockerfile"
"github.com/openshift/source-to-image/pkg/util/fs"
utillog "github.com/openshift/source-to-image/pkg/util/log"
)

// External represents the shell out for external build commands, therefore s2i based build can
// execute the generation of
type External struct {
dockerfile *dockerfile.Dockerfile
}

// s2iDockerfile Dockerfile default filename.
const s2iDockerfile = "Dockerfile.s2i"

var (
// local logger
log = utillog.StderrLog
// supported external commands, template is based on api.Config instance
commands = map[string]string{
"buildah": `buildah bud --tag {{ .Tag }} --file {{ .AsDockerfile }} {{ or .WorkingDir "." }}`,
"docker": `docker build --tag {{ .Tag }} --file {{ .AsDockerfile }} {{ or .WorkingDir "." }}`,
"podman": `podman build --tag {{ .Tag }} --file {{ .AsDockerfile }} {{ or .WorkingDir "." }}`,
}
)

// GetBuilders returns a list of command names, based global commands map.
func GetBuilders() []string {
builders := []string{}
for k := range commands {
builders = append(builders, k)
}
sort.Strings(builders)
return builders
}

// ValidBuilderName returns a boolean based in keys of global commands map.
func ValidBuilderName(name string) bool {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking out aloud, I would have implemented it this way :)

# external_builder.go

type ExternalBuilder interface{
         Build(config *api.Config) (*api.Result, error)
}


# buildah_builder.go


// validate at compile type if interface contract is met
var _ BuildahBuilder = (ExternalBuilder)(nil)

type BuildahBuilder struct{
	dockerfile *dockerfile.Dockerfile
}

func NewBuildahBuilder(config *api.Config){
	// initialize everything needed from config
}

func (*BuildahBuilder) Build() (*api.Result, error) {
	// use `buildah bud --tag {{ .Tag }} --file {{ .AsDockerfile }} {{ or .WorkingDir "." }}`
}


_, exists := commands[name]
return exists
}

// renderCommand render a shell command based in api.Config instance. Attribute WithBuilder
// wll determine external builder name, and api.Config feeds command's template variables. It can
// return error in case of template parsing or evaluation issues.
func (e *External) renderCommand(config *api.Config) (string, error) {
commandTemplate, exists := commands[config.WithBuilder]
if !exists {
return "", fmt.Errorf("cannot find command '%s' in dictionary: '%#v'",
config.WithBuilder, commands)
}

t, err := template.New("external-command").Parse(commandTemplate)
if err != nil {
return "", err
}
var output bytes.Buffer
if err = t.Execute(&output, config); err != nil {
return "", err
}
return output.String(), nil
}

// execute the given external command using "os/exec". Returns the outcomes as api.Result, making
// sure it only marks result as success when exit-code is zero. Therefore, it returns errors based
// in external command errors, so "s2i build" also fails.
func (e *External) execute(externalCommand string) (*api.Result, error) {
log.V(0).Infof("Executing external build command: '%s'", externalCommand)

externalCommandSlice := strings.Split(externalCommand, " ")
cmd := exec.Command(externalCommandSlice[0], externalCommandSlice[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

res := &api.Result{Success: true}
res.Messages = append(res.Messages, fmt.Sprintf("Running command: '%s'", externalCommand))
err := cmd.Start()
if err != nil {
res.Messages = append(res.Messages, err.Error())
return res, err
}

if err := cmd.Wait(); err != nil {
res.Success = false

if exitErr, okay := err.(*exec.ExitError); okay {
if status, okay := exitErr.Sys().(syscall.WaitStatus); okay {
exitCode := status.ExitStatus()
log.V(0).Infof("External command return-code: %d", exitCode)
res.Messages = append(res.Messages, fmt.Sprintf("exit-code: %d", exitCode))
return res, exitErr
}
}
}
return res, nil
}

// asDockerfile inspect config, if user has already informed `--as-dockerfile` option, that's simply
// returned, otherwise, considering `--working-dir` option first before using artificial name.
func (e *External) asDockerfile(config *api.Config) string {
if len(config.AsDockerfile) > 0 {
return config.AsDockerfile
}

if len(config.WorkingDir) > 0 {
return path.Join(config.WorkingDir, s2iDockerfile)
}
return s2iDockerfile
}

// Build triggers the build of a "strategy/dockerfile" to obtain "AsDockerfile" first, and then
// proceed to execute the external command.
func (e *External) Build(config *api.Config) (*api.Result, error) {
config.AsDockerfile = e.asDockerfile(config)

externalCommand, err := e.renderCommand(config)
if err != nil {
return nil, err
}

// generating dockerfile following AsDockerfile directive
_, err = e.dockerfile.Build(config)
if err != nil {
return nil, err
}

return e.execute(externalCommand)
}

// New instance of External command strategy.
func New(config *api.Config, fs fs.FileSystem) (*External, error) {
df, err := dockerfile.New(config, fs)
if err != nil {
return nil, err
}
return &External{dockerfile: df}, nil
}
152 changes: 152 additions & 0 deletions pkg/build/strategies/external/external_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package external

import (
"reflect"
"testing"

"github.com/openshift/source-to-image/pkg/api"
)

func TestExternalGetBuilders(t *testing.T) {
tests := []struct {
name string
want []string
}{
{"builders", []string{"buildah", "docker", "podman"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetBuilders(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("GetBuilders() = %v, want %v", got, tt.want)
}
})
}
}

func TestExternalValidBuilderName(t *testing.T) {
tests := []struct {
name string
args string
want bool
}{
{"valid-builder-name", "buildah", true},
{"valid-builder-name", "docker", true},
{"valid-builder-name", "podman", true},
{"invalid-builder-name", "other", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ValidBuilderName(tt.args); got != tt.want {
t.Errorf("ValidBuilderName() = %v, want %v", got, tt.want)
}
})
}
}

func TestExternalRenderCommand(t *testing.T) {
e := &External{}
tests := []struct {
name string
config *api.Config
want string
wantErr bool
}{
{
"render-buildah",
&api.Config{WithBuilder: "buildah", Tag: "tag", AsDockerfile: "dockerfile"},
"buildah bud --tag tag --file dockerfile .",
false,
},
{
"render-podman",
&api.Config{WithBuilder: "podman", Tag: "tag", AsDockerfile: "dockerfile"},
"podman build --tag tag --file dockerfile .",
false,
},
{
"render-docker",
&api.Config{WithBuilder: "docker", Tag: "tag", AsDockerfile: "dockerfile"},
"docker build --tag tag --file dockerfile .",
false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := e.renderCommand(tt.config)
if (err != nil) != tt.wantErr {
t.Errorf("External.renderCommand() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("External.renderCommand() = %v, want %v", got, tt.want)
}
})
}
}

func TestExternalAsDockerfile(t *testing.T) {
e := &External{}
tests := []struct {
name string
config *api.Config
want string
}{
{
"without-as-dockerfile-without-working-dir",
&api.Config{},
"Dockerfile.s2i",
},
{
"without-as-dockerfile-with-working-dir",
&api.Config{WorkingDir: "dir"},
"dir/Dockerfile.s2i",
},
{
"with-as-dockerfile-with-working-dir",
&api.Config{AsDockerfile: "Dockerfile", WorkingDir: "dir"},
"Dockerfile",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := e.asDockerfile(tt.config); got != tt.want {
t.Errorf("External.asDockerfile() = %v, want %v", got, tt.want)
}
})
}
}

func TestExternal_execute(t *testing.T) {
e := &External{}
tests := []struct {
name string
externalCommand string
want bool
wantErr bool
}{
{
"successful-true-command",
"true",
true,
false,
},
{
"error-false-command",
"false",
false,
true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := e.execute(tt.externalCommand)
if (err != nil) != tt.wantErr {
t.Errorf("External.execute() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.want != got.Success {
t.Errorf("External.execute() = %v, want %v", got, tt.want)
}
})
}
}
13 changes: 13 additions & 0 deletions pkg/build/strategies/strategies.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/openshift/source-to-image/pkg/api"
"github.com/openshift/source-to-image/pkg/build"
"github.com/openshift/source-to-image/pkg/build/strategies/dockerfile"
"github.com/openshift/source-to-image/pkg/build/strategies/external"
"github.com/openshift/source-to-image/pkg/build/strategies/onbuild"
"github.com/openshift/source-to-image/pkg/build/strategies/sti"
"github.com/openshift/source-to-image/pkg/docker"
Expand All @@ -24,6 +25,18 @@ func Strategy(client docker.Client, config *api.Config, overrides build.Override

startTime := time.Now()

if len(config.WithBuilder) != 0 {
builder, err = external.New(config, fileSystem)
if err != nil {
buildInfo.FailureReason = utilstatus.NewFailureReason(
utilstatus.ReasonGenericS2IBuildFailed,
utilstatus.ReasonMessageGenericS2iBuildFailed,
)
return nil, buildInfo, err
}
return builder, buildInfo, nil
}

if len(config.AsDockerfile) != 0 {
otaviof marked this conversation as resolved.
Show resolved Hide resolved
builder, err = dockerfile.New(config, fileSystem)
if err != nil {
Expand Down
Loading