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

Docker prototype #11

Merged
merged 7 commits into from
Sep 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()
}