Skip to content

Commit

Permalink
Docker prototype (#11)
Browse files Browse the repository at this point in the history
* Add execution container

* Remove deprecated function call

* Add support for slice script

* Add support for Git authentication

* Outsource image tags

* Add first MVP of Docker executor
  • Loading branch information
robertjndw authored Sep 24, 2023
1 parent f409694 commit 85f638d
Show file tree
Hide file tree
Showing 8 changed files with 234 additions and 35 deletions.
1 change: 1 addition & 0 deletions .idea/runConfigurations/HadesScheduler.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions HadesScheduler/config/images.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package config

const (
CloneContainerImage = "alpine/git:latest"
ResultContainerImage = "alpine:latest"
SharedVolumeName = "shared"
)
29 changes: 28 additions & 1 deletion HadesScheduler/docker/container.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,44 @@
package docker

import (
"context"
"github.com/Mtze/HadesCI/hadesScheduler/config"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"os"
)

var defaultHostConfig = container.HostConfig{
Mounts: []mount.Mount{
{
Type: mount.TypeVolume,
Source: sharedVolumeName,
Source: config.SharedVolumeName,
Target: "/shared",
},
},
AutoRemove: true,
}

func writeContainerLogsToFile(ctx context.Context, client *client.Client, containerID string, logFilePath string) error {
out, err := os.Create(logFilePath)
if err != nil {
return err
}
defer out.Close()

logReader, err := client.ContainerLogs(ctx, containerID, types.ContainerLogsOptions{
ShowStdout: true,
ShowStderr: true,
Timestamps: true,
})
if err != nil {
return err
}
defer logReader.Close()

_, err = stdcopy.StdCopy(out, out, logReader)
return err
}
174 changes: 153 additions & 21 deletions HadesScheduler/docker/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@ package docker

import (
"context"
"fmt"
"github.com/Mtze/HadesCI/hadesScheduler/config"
"io"
"sync"
"time"

"github.com/Mtze/HadesCI/shared/payload"
"github.com/Mtze/HadesCI/shared/utils"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/client"
_ "github.com/docker/docker/client"
log "github.com/sirupsen/logrus"
)

const (
cloneContainerImage = "alpine/git:latest"
sharedVolumeName = "shared"
"os"
)

var cli *client.Client
Expand All @@ -33,48 +35,103 @@ func init() {
func (d Scheduler) ScheduleJob(job payload.BuildJob) error {
ctx := context.Background()

startOfPull := time.Now()
// Pull the images
err := pullImages(ctx, cli, job.BuildConfig.ExecutionContainer, config.CloneContainerImage, config.ResultContainerImage)
if err != nil {
log.WithError(err).Error("Failed to pull images")
return err
}
log.Debugf("Pulled images in %s", time.Since(startOfPull))

startOfVolume := time.Now()
// Create the shared volume
err := createSharedVolume(ctx, cli, sharedVolumeName)
err = createSharedVolume(ctx, cli, config.SharedVolumeName)
if err != nil {
log.WithError(err).Error("Failed to create shared volume")
return err
}
log.Debugf("Create Shared Volume in %s", time.Since(startOfVolume))

startOfClone := time.Now()
// Clone the repository
err = cloneRepository(ctx, cli, job.BuildConfig.Repositories...)
err = cloneRepository(ctx, cli, job.Credentials, job.BuildConfig.Repositories...)
if err != nil {
log.WithError(err).Error("Failed to clone repository")
return err
}
log.Debugf("Clone repo in %s", time.Since(startOfClone))

startOfExecute := time.Now()
err = executeRepository(ctx, cli, job.BuildConfig)
if err != nil {
log.WithError(err).Error("Failed to execute repository")
return err
}
log.Debugf("Execute repo in %s", time.Since(startOfExecute))
log.Debugf("Total time: %s", time.Since(startOfPull))

// TODO enable deletion of shared volume
//time.Sleep(5 * time.Second)
//err = deleteSharedVolume(ctx, cli, sharedVolumeName)
//if err != nil {
// log.WithError(err).Error("Failed to delete shared volume")
// return err
//}
time.Sleep(1 * time.Second)
startOfDelete := time.Now()
err = deleteSharedVolume(ctx, cli, config.SharedVolumeName)
if err != nil {
log.WithError(err).Error("Failed to delete shared volume")
return err
}
log.Debugf("Delete Shared Volume in %s", time.Since(startOfDelete))

return nil
}

func cloneRepository(ctx context.Context, client *client.Client, repositories ...payload.Repository) error {
// Pull the image
_, err := client.ImagePull(ctx, cloneContainerImage, types.ImagePullOptions{})
if err != nil {
return err
func pullImages(ctx context.Context, client *client.Client, images ...string) error {
var wg sync.WaitGroup
errorsCh := make(chan error, len(images))

for _, image := range images {
wg.Add(1)

go func(img string) {
defer wg.Done()

response, err := client.ImagePull(ctx, img, types.ImagePullOptions{})
if err != nil {
errorsCh <- fmt.Errorf("failed to pull image %s: %v", img, err)
return
}
defer response.Close()
io.Copy(io.Discard, response) // consume the response to prevent potential leaks
}(image)
}

// wait for all goroutines to complete
wg.Wait()
close(errorsCh)

// Collect errors
var errors []error
for err := range errorsCh {
errors = append(errors, err)
}

if len(errors) > 0 {
return fmt.Errorf("encountered %d errors while pulling images: %+v", len(errors), errors)
}

return nil
}

func cloneRepository(ctx context.Context, client *client.Client, credentials payload.Credentials, repositories ...payload.Repository) error {
// Use the index to modify the slice in place
for i := range repositories {
repositories[i].Path = "/shared" + repositories[i].Path
}
commandStr := utils.BuildCloneCommands(repositories...)
commandStr := utils.BuildCloneCommands(credentials, repositories...)
log.Debug(commandStr)

// Create the container
resp, err := client.ContainerCreate(ctx, &container.Config{
Image: cloneContainerImage,
Image: config.CloneContainerImage,
Entrypoint: []string{"/bin/sh", "-c"},
Cmd: []string{commandStr},
Volumes: map[string]struct{}{
Expand All @@ -91,6 +148,81 @@ func cloneRepository(ctx context.Context, client *client.Client, repositories ..
return err
}

log.Infof("Container %s started with ID: %s\n", cloneContainerImage, resp.ID)
statusCh, errCh := client.ContainerWait(ctx, resp.ID, container.WaitConditionNotRunning)
select {
case err := <-errCh:
if err != nil {
log.WithError(err).Errorf("Error waiting for container with ID %s", resp.ID)
return err
}
case status := <-statusCh:
if status.StatusCode != 0 {
log.Errorf("Container with ID %s exited with status %d", resp.ID, status.StatusCode)
return fmt.Errorf("container exited with status %d", status.StatusCode)
}
}

log.Infof("Container %s started with ID: %s\n", config.CloneContainerImage, resp.ID)
return nil
}

func executeRepository(ctx context.Context, client *client.Client, buildConfig payload.BuildConfig) error {
// First, write the Bash script to a temporary file
scriptPath, err := writeBashScriptToFile("cd /shared", buildConfig.BuildScript)
if err != nil {
log.WithError(err).Error("Failed to write bash script to a temporary file")
return err
}
defer os.Remove(scriptPath)

hostConfigWithScript := defaultHostConfig
hostConfigWithScript.Mounts = append(defaultHostConfig.Mounts, mount.Mount{
Type: mount.TypeBind,
Source: scriptPath,
Target: "/tmp/script.sh",
})
// Create the container
resp, err := client.ContainerCreate(ctx, &container.Config{
Image: buildConfig.ExecutionContainer,
Entrypoint: []string{"/bin/sh", "/tmp/script.sh"},
Volumes: map[string]struct{}{
"/shared": {},
"/tmp/script.sh": {}, // this volume will hold our script
},
}, &hostConfigWithScript, nil, nil, "")
if err != nil {
return err
}

// Start the container
err = client.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{})
if err != nil {
return err
}

// Wait for the container to finish
statusCh, errCh := client.ContainerWait(ctx, resp.ID, container.WaitConditionNotRunning)
select {
case err := <-errCh:
if err != nil {
log.WithError(err).Errorf("Error waiting for container %s with ID %s", buildConfig.ExecutionContainer, resp.ID)
return err
}
case status := <-statusCh:
if status.StatusCode != 0 {
log.Errorf("Container %s with ID %s exited with status %d", buildConfig.ExecutionContainer, resp.ID, status.StatusCode)
return fmt.Errorf("container exited with status %d", status.StatusCode)
}
}

// Fetch logs and write to a file
logFilePath := "./logfile.log" // TODO make this configurable
err = writeContainerLogsToFile(ctx, client, resp.ID, logFilePath)
if err != nil {
log.WithError(err).Errorf("Failed to write logs of container %s with ID %s", buildConfig.ExecutionContainer, resp.ID)
return err
}

log.Infof("Container %s with ID: %s completed", buildConfig.ExecutionContainer, resp.ID)
return nil
}
24 changes: 24 additions & 0 deletions HadesScheduler/docker/utils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package docker

import (
"os"
"strings"
)

func writeBashScriptToFile(bashScriptLines ...string) (string, error) {
bashScriptContent := strings.Join(bashScriptLines, "\n")
tmpFile, err := os.CreateTemp("", "bash-script-*.sh")
if err != nil {
return "", err
}

_, err = tmpFile.Write([]byte(bashScriptContent))
if err != nil {
tmpFile.Close()
return "", err
}

path := tmpFile.Name()
tmpFile.Close()
return path, nil
}
1 change: 0 additions & 1 deletion HadesScheduler/docker/volume.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,5 @@ func deleteSharedVolume(ctx context.Context, client *client.Client, name string)
return err
}

log.Debugf("Volume %s deleted", name)
return nil
}
21 changes: 13 additions & 8 deletions shared/payload/payload.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@ package payload
type BuildJob struct {
Name string `json:"name" binding:"required"`

Credentials struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
} `json:"credentials" binding:"required"`
BuildConfig struct {
Repositories []Repository `json:"repositories" binding:"required,dive"`
ExecutionContainer string `json:"executionContainer" binding:"required"`
} `json:"buildConfig" binding:"required"`
Credentials Credentials `json:"credentials" binding:"required"`
BuildConfig BuildConfig `json:"buildConfig" binding:"required"`
}

type Credentials struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}

type BuildConfig struct {
Repositories []Repository `json:"repositories" binding:"required,dive"`
ExecutionContainer string `json:"executionContainer" binding:"required"`
BuildScript string `json:"buildScript" binding:"required"`
}

type Repository struct {
Expand Down
12 changes: 8 additions & 4 deletions shared/utils/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,24 @@ package utils
import (
"fmt"
"github.com/Mtze/HadesCI/shared/payload"
"net/url"
"strings"
)

func BuildCloneCommand(repo payload.Repository) string {
return fmt.Sprintf("git clone %s %s", repo.URL, repo.Path)
func BuildCloneCommand(username, password string, repo payload.Repository) string {
username = url.PathEscape(username)
password = url.PathEscape(password)
cloneURL := strings.Replace(repo.URL, "https://", fmt.Sprintf("https://%s:%s@", username, password), 1)
return fmt.Sprintf("git clone %s %s", cloneURL, repo.Path)
}

func BuildCloneCommands(repos ...payload.Repository) string {
func BuildCloneCommands(credentials payload.Credentials, repos ...payload.Repository) string {
var builder strings.Builder
for i, repo := range repos {
if i > 0 {
builder.WriteString(" && ")
}
builder.WriteString(BuildCloneCommand(repo))
builder.WriteString(BuildCloneCommand(credentials.Username, credentials.Password, repo))
}
return builder.String()
}

0 comments on commit 85f638d

Please sign in to comment.