Skip to content

Commit

Permalink
feat: implement kaniko builder (#227)
Browse files Browse the repository at this point in the history
* feat: implement kaniko builder

* fix: applied the requested changes/suggestions

* fix: a typo in number 5

* fix: extract constants
  • Loading branch information
mojtaba-esk authored Jan 17, 2024
1 parent 4e51835 commit ababbf2
Show file tree
Hide file tree
Showing 8 changed files with 523 additions and 0 deletions.
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/docker/docker v24.0.7+incompatible
github.com/google/uuid v1.5.0
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.8.4
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.29.0
k8s.io/apimachinery v0.29.0
Expand All @@ -21,6 +22,7 @@ require (
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/evanphx/json-patch v4.12.0+incompatible // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect
Expand All @@ -47,6 +49,7 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.0.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84=
github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA=
Expand Down
21 changes: 21 additions & 0 deletions pkg/builder/builder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package builder

import "context"

type Builder interface {
Build(ctx context.Context, b *BuilderOptions) (logs string, err error)
}

type CacheOptions struct {
Enabled bool
Dir string
Repo string
}

type BuilderOptions struct {
ImageName string
BuildContext string
Args []string
Destination string
Cache *CacheOptions
}
43 changes: 43 additions & 0 deletions pkg/builder/kaniko/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package kaniko

import (
"fmt"
)

type Error struct {
Code string
Message string
Err error
}

func (e *Error) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Err)
}
return e.Message
}

func (e *Error) Wrap(err error) error {
e.Err = err
return e
}

var (
ErrBuildContextEmpty = &Error{Code: "BuildContextEmpty", Message: "build context cannot be empty"}
ErrCleaningUp = &Error{Code: "CleaningUp", Message: "error cleaning up"}
ErrCreatingJob = &Error{Code: "CreatingJob", Message: "error creating Job"}
ErrDeletingJob = &Error{Code: "DeletingJob", Message: "error deleting Job"}
ErrDeletingPods = &Error{Code: "DeletingPods", Message: "error deleting Pods"}
ErrGeneratingUUID = &Error{Code: "GeneratingUUID", Message: "error generating UUID"}
ErrGettingContainerLogs = &Error{Code: "GettingContainerLogs", Message: "error getting container logs"}
ErrGettingPodFromJob = &Error{Code: "GettingPodFromJob", Message: "error getting Pod from Job"}
ErrListingJobs = &Error{Code: "ListingJobs", Message: "error listing Jobs"}
ErrListingPods = &Error{Code: "ListingPods", Message: "error listing Pods"}
ErrNoContainersFound = &Error{Code: "NoContainersFound", Message: "no containers found"}
ErrNoPodsFound = &Error{Code: "NoPodsFound", Message: "no Pods found"}
ErrPreparingJob = &Error{Code: "PreparingJob", Message: "error preparing Job"}
ErrWaitingJobCompletion = &Error{Code: "WaitingJobCompletion", Message: "error waiting for Job completion"}
ErrWatchingChannelCloseUnexpectedly = &Error{Code: "WatchingChannelCloseUnexpectedly", Message: "watch channel closed unexpectedly"}
ErrWatchingJob = &Error{Code: "WatchingJob", Message: "error watching Job"}
ErrContextCancelled = &Error{Code: "ContextCancelled", Message: "context cancelled"}
)
53 changes: 53 additions & 0 deletions pkg/builder/kaniko/git.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package kaniko

import (
"regexp"
"strings"
)

const (
regexpGitRepoProtocol = `^(https?|git|ssh|ftp)://`
regexpGitRepoDotGit = `\.git$`
gitProtocol = "git://"
)

type GitContext struct {
Repo string
Commit string
Username string
Password string
}

func (g *GitContext) BuildContext() (string, error) {
bCtx := ""

// cleaning the repo url
rgx, err := regexp.Compile(regexpGitRepoProtocol)
if err != nil {
return "", err
}
g.Repo = rgx.ReplaceAllString(g.Repo, "")

rgx, err = regexp.Compile(regexpGitRepoDotGit)
if err != nil {
return "", err
}
g.Repo = rgx.ReplaceAllString(g.Repo, "")
g.Repo = strings.TrimSuffix(g.Repo, "/")

bCtx += gitProtocol
if g.Username != "" {
bCtx += g.Username
if g.Password != "" {
bCtx += ":" + g.Password
}
bCtx += "@"
}

bCtx += g.Repo
if g.Commit != "" {
bCtx += "#" + g.Commit
}

return bCtx, nil
}
225 changes: 225 additions & 0 deletions pkg/builder/kaniko/kaniko.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
package kaniko

import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"

"github.com/celestiaorg/knuu/pkg/builder"
"github.com/celestiaorg/knuu/pkg/names"
batchv1 "k8s.io/api/batch/v1"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)

const (
kanikoImage = "gcr.io/kaniko-project/executor:debug" // debug has a shell
kanikoContainerName = "kaniko-container"
kanikoJobNamePrefix = "kaniko-build-job"

DefaultParallelism = int32(5)
DefaultBackoffLimit = int32(5)
)

type Kaniko struct {
K8sClientset kubernetes.Interface
K8sNamespace string
}

var _ builder.Builder = &Kaniko{}

func (k *Kaniko) Build(ctx context.Context, b *builder.BuilderOptions) (logs string, err error) {
job, err := prepareJob(b)
if err != nil {
return "", ErrPreparingJob.Wrap(err)
}

cJob, err := k.K8sClientset.BatchV1().Jobs(k.K8sNamespace).Create(ctx, job, metav1.CreateOptions{})
if err != nil {
return "", ErrCreatingJob.Wrap(err)
}

kJob, err := k.waitForJobCompletion(ctx, cJob)
if err != nil {
return "", ErrWaitingJobCompletion.Wrap(err)
}

pod, err := k.firstPodFromJob(ctx, kJob)
if err != nil {
return "", ErrGettingPodFromJob.Wrap(err)
}

logs, err = k.containerLogs(ctx, pod)
if err != nil {
return "", ErrGettingContainerLogs.Wrap(err)
}

if err := k.cleanup(ctx, kJob); err != nil {
return "", ErrCleaningUp.Wrap(err)
}

return logs, nil
}

func (k *Kaniko) waitForJobCompletion(ctx context.Context, job *batchv1.Job) (*batchv1.Job, error) {
watcher, err := k.K8sClientset.BatchV1().Jobs(k.K8sNamespace).Watch(ctx, metav1.ListOptions{
FieldSelector: fmt.Sprintf("metadata.name=%s", job.Name),
})
if err != nil {
return nil, ErrWatchingJob.Wrap(err)
}
defer watcher.Stop()

for {
select {
case event, ok := <-watcher.ResultChan():
if !ok {
return nil, ErrWatchingChannelCloseUnexpectedly
}

j, ok := event.Object.(*batchv1.Job)
if !ok {
continue
}

if j.Status.Succeeded > 0 || j.Status.Failed > 0 {
// Job completed (successfully or failed)
return j, nil
}
case <-ctx.Done():
return nil, ErrContextCancelled
}
}
}

func (k *Kaniko) firstPodFromJob(ctx context.Context, job *batchv1.Job) (*v1.Pod, error) {
podList, err := k.K8sClientset.CoreV1().Pods(k.K8sNamespace).List(ctx, metav1.ListOptions{
LabelSelector: fmt.Sprintf("job-name=%s", job.Name),
})
if err != nil {
return nil, ErrListingPods.Wrap(err)
}

if len(podList.Items) == 0 {
return nil, ErrNoPodsFound.Wrap(fmt.Errorf("job: %s", job.Name))
}

return &podList.Items[0], nil
}

func (k *Kaniko) containerLogs(ctx context.Context, pod *v1.Pod) (string, error) {
if len(pod.Spec.Containers) == 0 {
return "", ErrNoContainersFound.Wrap(fmt.Errorf("pod: %s", pod.Name))
}

containerName := pod.Spec.Containers[0].Name

logOptions := v1.PodLogOptions{
Container: containerName,
}

req := k.K8sClientset.CoreV1().Pods(k.K8sNamespace).GetLogs(pod.Name, &logOptions)
logs, err := req.DoRaw(ctx)
if err != nil {
return "", err
}

return string(logs), nil
}

func (k *Kaniko) cleanup(ctx context.Context, job *batchv1.Job) error {
err := k.K8sClientset.BatchV1().Jobs(k.K8sNamespace).
Delete(ctx, job.Name, metav1.DeleteOptions{
PropagationPolicy: &[]metav1.DeletionPropagation{metav1.DeletePropagationBackground}[0],
})
if err != nil {
return ErrDeletingJob.Wrap(err)
}

// Delete the associated Pods
err = k.K8sClientset.CoreV1().Pods(k.K8sNamespace).
DeleteCollection(ctx, metav1.DeleteOptions{}, metav1.ListOptions{
LabelSelector: fmt.Sprintf("job-name=%s", job.Name),
})
if err != nil {
return ErrDeletingPods.Wrap(err)
}

return nil
}

func DefaultCacheOptions(buildContext string) (*builder.CacheOptions, error) {
if buildContext == "" {
return nil, ErrBuildContextEmpty
}
hash := sha256.New()
_, err := hash.Write([]byte(buildContext))
if err != nil {
return nil, err
}
hashStr := hex.EncodeToString(hash.Sum(nil))

return &builder.CacheOptions{
Enabled: true,
Dir: "",
Repo: fmt.Sprintf("ttl.sh/%s:24h", hashStr),
}, nil
}

func prepareJob(b *builder.BuilderOptions) (*batchv1.Job, error) {
jobName, err := names.NewRandomK8(kanikoJobNamePrefix)
if err != nil {
return nil, ErrGeneratingUUID.Wrap(err)
}

parallelism := DefaultParallelism
backoffLimit := DefaultBackoffLimit
job := &batchv1.Job{
ObjectMeta: metav1.ObjectMeta{
Name: jobName,
},
Spec: batchv1.JobSpec{
Parallelism: &parallelism, // Set parallelism to 1 to ensure only one Pod
BackoffLimit: &backoffLimit, // Retry the Job at most 5 times
Template: v1.PodTemplateSpec{
Spec: v1.PodSpec{
Containers: []v1.Container{
{
Name: kanikoContainerName,
Image: kanikoImage, // debug has a shell
Args: []string{
`--context=` + b.BuildContext,
// TODO: see if we need it or not
// --git gitoptions Branch to clone if build context is a git repository (default branch=,single-branch=false,recurse-submodules=false)

// TODO: we might need to add some options to get the auth token for the registry
"--destination=" + b.Destination,
},
},
},
RestartPolicy: "Never", // Ensure that the Pod does not restart
},
},
},
}

// TODO: we need to add some configs to get the auth token for the cache repo
if b.Cache != nil && b.Cache.Enabled {
cacheArgs := []string{"--cache=true"}
if b.Cache.Dir != "" {
cacheArgs = append(cacheArgs, "--cache-dir="+b.Cache.Dir)
}
if b.Cache.Repo != "" {
cacheArgs = append(cacheArgs, "--cache-repo="+b.Cache.Repo)
}
job.Spec.Template.Spec.Containers[0].Args = append(job.Spec.Template.Spec.Containers[0].Args, cacheArgs...)
}

// Add extra args
job.Spec.Template.Spec.Containers[0].Args = append(job.Spec.Template.Spec.Containers[0].Args, b.Args...)

return job, nil

}
Loading

0 comments on commit ababbf2

Please sign in to comment.