This repository has been archived by the owner on Jan 9, 2025. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Feat: Add image building and pushing (#23)
* 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
1 parent
84afd40
commit 25b5eff
Showing
2 changed files
with
356 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() {}))) | ||
}) | ||
} | ||
} |