Skip to content

Commit

Permalink
Feat: Add image building and pushing (#23)
Browse files Browse the repository at this point in the history
* initial commit

* implement `buildCmdImage` and add required types

* add `ImagePush` and supporting struct

* remove `populateCache` argument

* refactor based on review

* update to fit the PKO magefile

* make `ImagePushInfo.DigestFile` optional

* add unit tests for `build.go`

* remove debugging print statement

* add package building using the PKO CLI
  • Loading branch information
pbabic-redhat authored Feb 9, 2023
1 parent 84afd40 commit 25b5eff
Show file tree
Hide file tree
Showing 2 changed files with 356 additions and 0 deletions.
136 changes: 136 additions & 0 deletions dev/build.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package dev

import (
"fmt"
"github.com/magefile/mage/mg"
"log"
"os"
"os/exec"
"strings"
)

type ImageBuildInfo struct {
ImageTag string
CacheDir string
ContainerFile string
ContextDir string
Runtime string
}

type PackageBuildInfo struct {
ImageTag string
CacheDir string
SourcePath string // source directory
OutputPath string // destination: .tar file path
Runtime string
}

type ImagePushInfo struct {
ImageTag string
CacheDir string
Runtime string
DigestFile string
}

// execCommand is replaced with helper function when testing
var execCommand = exec.Command

func execError(command []string, err error) error {
return fmt.Errorf("running command '%s': %w", strings.Join(command, " "), err)
}

func newExecCmd(args []string, cacheDir string) *exec.Cmd {
cmd := execCommand(args[0], args[1:]...)
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
cmd.Dir = cacheDir
return cmd
}

// BuildImage is a generic image build function,
// requires the binaries to be built beforehand
func BuildImage(buildInfo *ImageBuildInfo, deps []interface{}) error {
if len(deps) > 0 {
mg.SerialDeps(deps...)
}

buildCmdArgs := []string{buildInfo.Runtime, "build", "-t", buildInfo.ImageTag}
if buildInfo.ContainerFile != "" {
buildCmdArgs = append(buildCmdArgs, "-f", buildInfo.ContainerFile)
}
buildCmdArgs = append(buildCmdArgs, buildInfo.ContextDir)

commands := [][]string{
buildCmdArgs,
{buildInfo.Runtime, "image", "save", "-o", buildInfo.CacheDir + ".tar", buildInfo.ImageTag},
}

// Build image!
for _, command := range commands {
buildCmd := newExecCmd(command, buildInfo.CacheDir)
if err := buildCmd.Run(); err != nil {
return execError(command, err)
}
}
return nil
}

// BuildPackage builds a package image using the package operator CLI,
// requires `kubectl package` command to be available on the system
func BuildPackage(buildInfo *PackageBuildInfo, deps []interface{}) error {
if len(deps) > 0 {
mg.SerialDeps(deps...)
}

buildArgs := []string{
"kubectl", "package", "build", "--tag", buildInfo.ImageTag,
"--output", buildInfo.OutputPath, buildInfo.SourcePath,
}
importArgs := []string{
buildInfo.Runtime, "import", buildInfo.OutputPath, buildInfo.ImageTag,
}

for _, args := range [][]string{buildArgs, importArgs} {
command := newExecCmd(args, buildInfo.CacheDir)
if err := command.Run(); err != nil {
return execError(args, err)
}
}
return nil
}

func quayLogin(runtime, cacheDir string) error {
args := []string{runtime, "login", "-u=" + os.Getenv("QUAY_USER"), "-p=" + os.Getenv("QUAY_TOKEN"), "quay.io"}
loginCmd := newExecCmd(args, cacheDir)
if err := loginCmd.Run(); err != nil {
return execError(args, err)
}
return nil
}

// PushImage pushes only the given container image to the default registry.
func PushImage(pushInfo *ImagePushInfo, buildImageDep mg.Fn) error {
mg.SerialDeps(buildImageDep)

// Login to container registry when running on AppSRE Jenkins.
_, isJenkins := os.LookupEnv("JENKINS_HOME")
_, isCI := os.LookupEnv("CI")
if isJenkins || isCI {
log.Println("running in CI, calling container runtime login")
if err := quayLogin(pushInfo.Runtime, pushInfo.CacheDir); err != nil {
return err
}
}

args := []string{pushInfo.Runtime, "push"}
if pushInfo.Runtime == string(ContainerRuntimePodman) && pushInfo.DigestFile != "" {
args = append(args, "--digestfile="+pushInfo.DigestFile)
}
args = append(args, pushInfo.ImageTag)

pushCmd := newExecCmd(args, pushInfo.CacheDir)
if err := pushCmd.Run(); err != nil {
return execError(args, err)
}
return nil
}
220 changes: 220 additions & 0 deletions dev/build_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
package dev

import (
"github.com/magefile/mage/mg"
"github.com/stretchr/testify/assert"
"os"
"os/exec"
"testing"
)

type buildImgTestCase struct {
name string
buildInfo ImageBuildInfo
buildCmd []string
saveCmd []string
}

type buildPkgTestCase struct {
name string
buildInfo PackageBuildInfo
buildCmd []string
importCmd []string
}

type pushTestCase struct {
name string
pushInfo ImagePushInfo
pushCmd []string
loginCmd []string
}

var (
defaultBuildImgCase = buildImgTestCase{
name: "default",
buildInfo: ImageBuildInfo{
ImageTag: "test_ImageTag",
CacheDir: "",
ContainerFile: "test_ContainerFile",
ContextDir: "test_ContextDir",
Runtime: "test_Runtime",
},
buildCmd: []string{"test_Runtime", "build", "-t", "test_ImageTag", "-f", "test_ContainerFile", "test_ContextDir"},
saveCmd: []string{"test_Runtime", "image", "save", "-o", ".tar", "test_ImageTag"},
}

noConFileBuildImgCase = buildImgTestCase{
name: "no-container-file",
buildInfo: ImageBuildInfo{
ImageTag: "test_ImageTag",
CacheDir: "",
ContainerFile: "",
ContextDir: "test_ContextDir",
Runtime: "test_Runtime",
},
buildCmd: []string{"test_Runtime", "build", "-t", "test_ImageTag", "test_ContextDir"},
saveCmd: []string{"test_Runtime", "image", "save", "-o", ".tar", "test_ImageTag"},
}

defaultBuildPkgCase = buildPkgTestCase{
name: "default",
buildInfo: PackageBuildInfo{
ImageTag: "test_ImageTag",
CacheDir: "",
SourcePath: "test_SourcePath",
OutputPath: "test_OutputPath",
Runtime: "test_Runtime",
},
buildCmd: []string{"kubectl", "package", "build", "--tag", "test_ImageTag", "--output", "test_OutputPath", "test_SourcePath"},
importCmd: []string{"test_Runtime", "import", "test_OutputPath", "test_ImageTag"},
}

defaultPushCase = pushTestCase{
name: "default",
pushInfo: ImagePushInfo{
ImageTag: "test_ImageTag",
CacheDir: "",
Runtime: "test_Runtime",
DigestFile: "test_DigestFile",
},
pushCmd: []string{"test_Runtime", "push", "test_ImageTag"},
loginCmd: []string{"test_Runtime", "login", "-u=" + os.Getenv("QUAY_USER"), "-p=" + os.Getenv("QUAY_TOKEN"), "quay.io"},
}

podmanPushCase = pushTestCase{
name: "podman",
pushInfo: ImagePushInfo{
ImageTag: "test_ImageTag",
CacheDir: "",
Runtime: string(ContainerRuntimePodman),
DigestFile: "test_DigestFile",
},
pushCmd: []string{string(ContainerRuntimePodman), "push", "--digestfile=test_DigestFile", "test_ImageTag"},
loginCmd: []string{string(ContainerRuntimePodman), "login", "-u=" + os.Getenv("QUAY_USER"), "-p=" + os.Getenv("QUAY_TOKEN"), "quay.io"},
}

buildImgTestCases = map[string]*buildImgTestCase{
"default": &defaultBuildImgCase,
"no-container-file": &noConFileBuildImgCase,
}

buildPkgTestCases = map[string]*buildPkgTestCase{
"default": &defaultBuildPkgCase,
}

pushTestCases = map[string]*pushTestCase{
"default": &defaultPushCase,
"podman": &podmanPushCase,
}

// currentTestCase is used in TestXXXX_HelperProcess to identify which test ran it
currentTestCase string

// helperProcess is used by mockExecCommand to determine which helper process to run
helperProcess string
)

const (
buildImgHelper = "TestBuildImage_HelperProcess"
buildPkgHelper = "TestBuildPackage_HelperProcess"
pushHelper = "TestPushImage_HelperProcess"
)

func mockExecCommand(command string, args ...string) *exec.Cmd {
cs := []string{"-test.run=" + helperProcess, "--", command}
cs = append(cs, args...)
cmd := exec.Command(os.Args[0], cs...)
cmd.Env = []string{
"GO_WANT_HELPER_PROCESS=1",
"GO_TEST_CASE_NAME=" + currentTestCase,
}
return cmd
}

func TestBuildImage_HelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
tc := buildImgTestCases[os.Getenv("GO_TEST_CASE_NAME")]
command := os.Args[3:]
switch command[1] {
case "build":
assert.Equal(t, tc.buildCmd, command)
case "image":
assert.Equal(t, tc.saveCmd, command)
default:
t.Errorf("invalid command")
}
os.Exit(0)
}

func TestBuildImage(t *testing.T) {
execCommand = mockExecCommand
defer func() { execCommand = exec.Command }()
helperProcess = buildImgHelper

for _, tc := range buildImgTestCases {
currentTestCase = tc.name
t.Run(tc.name, func(t *testing.T) {
assert.NoError(t, BuildImage(&tc.buildInfo, []interface{}{}))
})
}
}

func TestBuildPackage_HelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
tc := buildPkgTestCases[os.Getenv("GO_TEST_CASE_NAME")]
command := os.Args[3:]
switch command[1] {
case "package":
assert.Equal(t, tc.buildCmd, command)
case "import":
assert.Equal(t, tc.importCmd, command)
default:
t.Errorf("invalid command")
}
os.Exit(0)
}

func TestBuildPackage(t *testing.T) {
execCommand = mockExecCommand
defer func() { execCommand = exec.Command }()
helperProcess = buildPkgHelper

for _, tc := range buildPkgTestCases {
currentTestCase = tc.name
t.Run(tc.name, func(t *testing.T) {
assert.NoError(t, BuildPackage(&tc.buildInfo, []interface{}{}))
})
}
}

func TestPushImage_HelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
tc := pushTestCases[os.Getenv("GO_TEST_CASE_NAME")]
command := os.Args[3:]
switch command[1] {
case "push":
assert.Equal(t, tc.pushCmd, command)
case "login":
assert.Equal(t, tc.loginCmd, command)
}
os.Exit(0)
}

func TestPushImage(t *testing.T) {
execCommand = mockExecCommand
defer func() { execCommand = exec.Command }()
helperProcess = pushHelper

for _, tc := range pushTestCases {
currentTestCase = tc.name
t.Run(tc.name, func(t *testing.T) {
assert.NoError(t, PushImage(&tc.pushInfo, mg.F(func() {})))
})
}
}

0 comments on commit 25b5eff

Please sign in to comment.