Skip to content

Commit

Permalink
feat: add support to deploy multiple stacks (#140)
Browse files Browse the repository at this point in the history
* feat: add support to deploy multiple stacks

* test: adjust expected response strings

* refactor: adjust log

* refactor: adjust jobLog to stack context

* refactor: adjust jobLog to stack context

* refactor: move stack deployment logic to separate function

* test: adjust expected response strings

* refactor: format code

* fix: adjust http status codes

* refactor: change func args to pointers

* fix: remove superfluous response.WriteHeader call

* fix: remove superfluous response.WriteHeader call

* refactor: adjust error log

* refactor: adjust error log
  • Loading branch information
kimdre authored Sep 15, 2024
1 parent 0b14b44 commit 3baa5df
Show file tree
Hide file tree
Showing 7 changed files with 164 additions and 119 deletions.
170 changes: 86 additions & 84 deletions cmd/doco-cd/http_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
Expand All @@ -29,7 +30,7 @@ type handlerData struct {
func HandleEvent(ctx context.Context, jobLog *slog.Logger, w http.ResponseWriter, c *config.AppConfig, p webhook.ParsedPayload, jobID string, dockerCli command.Cli) {
jobLog = jobLog.With(slog.String("repository", p.FullName))

jobLog.Info("preparing project deployment")
jobLog.Info("preparing stack deployment")

// Clone the repository
jobLog.Debug(
Expand Down Expand Up @@ -85,8 +86,9 @@ func HandleEvent(ctx context.Context, jobLog *slog.Logger, w http.ResponseWriter
}

fs := worktree.Filesystem
rootDir := fs.Root()

jobLog.Debug("repository cloned", slog.String("path", fs.Root()))
jobLog.Debug("repository cloned", slog.String("path", rootDir))

// Defer removal of the repository
defer func(workDir string) {
Expand All @@ -102,12 +104,12 @@ func HandleEvent(ctx context.Context, jobLog *slog.Logger, w http.ResponseWriter
jobID,
http.StatusInternalServerError)
}
}(fs.Root())
}(rootDir)

jobLog.Debug("retrieving deployment configuration")

// Get the deployment config from the repository
deployConfig, err := config.GetDeployConfig(fs.Root(), p.Name)
// Get the deployment configs from the repository
deployConfigs, err := config.GetDeployConfigs(rootDir, p.Name)
if err != nil {
if errors.Is(err, config.ErrDeprecatedConfig) {
jobLog.Warn(err.Error())
Expand All @@ -124,89 +126,17 @@ func HandleEvent(ctx context.Context, jobLog *slog.Logger, w http.ResponseWriter
}
}

jobLog = jobLog.With(slog.String("reference", deployConfig.Reference))

jobLog.Debug("deployment configuration retrieved", slog.Any("config", deployConfig))

workingDir := path.Join(fs.Root(), deployConfig.WorkingDirectory)

err = os.Chdir(workingDir)
if err != nil {
errMsg = "failed to change working directory"
jobLog.Error(errMsg, logger.ErrAttr(err), slog.String("path", workingDir))
JSONError(w,
errMsg,
err.Error(),
jobID,
http.StatusInternalServerError)

return
}

// Check if the default compose files are used
if reflect.DeepEqual(deployConfig.ComposeFiles, cli.DefaultFileNames) {
var tmpComposeFiles []string

jobLog.Debug("checking for default compose files")

// Check if the default compose files exist
for _, f := range deployConfig.ComposeFiles {
if _, err = os.Stat(path.Join(workingDir, f)); errors.Is(err, os.ErrNotExist) {
continue
}

tmpComposeFiles = append(tmpComposeFiles, f)
}

if len(tmpComposeFiles) == 0 {
errMsg = "no compose files found"
jobLog.Error(errMsg,
slog.Group("compose_files", slog.Any("files", deployConfig.ComposeFiles)))
JSONError(w,
errMsg,
err.Error(),
jobID,
http.StatusInternalServerError)

for _, deployConfig := range deployConfigs {
err = deployStack(jobLog, jobID, rootDir, &w, &ctx, &dockerCli, &p, deployConfig)
if err != nil {
msg := "deployment failed"
jobLog.Error(msg)
JSONError(w, err, msg, jobID, http.StatusInternalServerError)
return
}

deployConfig.ComposeFiles = tmpComposeFiles
}

project, err := docker.LoadCompose(ctx, workingDir, deployConfig.Name, deployConfig.ComposeFiles)
if err != nil {
errMsg = "failed to load project"
jobLog.Error(errMsg,
logger.ErrAttr(err),
slog.Group("compose_files", slog.Any("files", deployConfig.ComposeFiles)))
JSONError(w,
errMsg,
err.Error(),
jobID,
http.StatusInternalServerError)

return
}

jobLog.Info("deploying project")

err = docker.DeployCompose(ctx, dockerCli, project, deployConfig, p)
if err != nil {
errMsg = "failed to deploy project"
jobLog.Error(errMsg,
logger.ErrAttr(err),
slog.Group("compose_files", slog.Any("files", deployConfig.ComposeFiles)))
JSONError(w,
errMsg,
err.Error(),
jobID,
http.StatusInternalServerError)

return
}

msg := "project deployment successful"
msg := "deployment successful"
jobLog.Info(msg)
JSONResponse(w, msg, jobID, http.StatusCreated)
}
Expand Down Expand Up @@ -266,3 +196,75 @@ func (h *handlerData) HealthCheckHandler(w http.ResponseWriter, _ *http.Request)
h.log.Debug("health check successful")
JSONResponse(w, "healthy", "", http.StatusOK)
}

func deployStack(
jobLog *slog.Logger, jobID, rootDir string,
w *http.ResponseWriter, ctx *context.Context,
dockerCli *command.Cli, p *webhook.ParsedPayload, deployConfig *config.DeployConfig,
) error {
stackLog := jobLog.
With(slog.String("stack", deployConfig.Name)).
With(slog.String("reference", deployConfig.Reference))

stackLog.Debug("deployment configuration retrieved", slog.Any("config", deployConfig))

workingDir := path.Join(rootDir, deployConfig.WorkingDirectory)

err := os.Chdir(workingDir)
if err != nil {
errMsg = "failed to change working directory"
jobLog.Error(errMsg, logger.ErrAttr(err), slog.String("path", workingDir))

return fmt.Errorf("%s: %w", errMsg, err)
}

// Check if the default compose files are used
if reflect.DeepEqual(deployConfig.ComposeFiles, cli.DefaultFileNames) {
var tmpComposeFiles []string

jobLog.Debug("checking for default compose files")

// Check if the default compose files exist
for _, f := range deployConfig.ComposeFiles {
if _, err = os.Stat(path.Join(workingDir, f)); errors.Is(err, os.ErrNotExist) {
continue
}

tmpComposeFiles = append(tmpComposeFiles, f)
}

if len(tmpComposeFiles) == 0 {
errMsg = "no compose files found"
stackLog.Error(errMsg,
slog.Group("compose_files", slog.Any("files", deployConfig.ComposeFiles)))

return fmt.Errorf("%s: %w", errMsg, err)
}

deployConfig.ComposeFiles = tmpComposeFiles
}

project, err := docker.LoadCompose(*ctx, workingDir, deployConfig.Name, deployConfig.ComposeFiles)
if err != nil {
errMsg = "failed to load compose config"
stackLog.Error(errMsg,
logger.ErrAttr(err),
slog.Group("compose_files", slog.Any("files", deployConfig.ComposeFiles)))

return fmt.Errorf("%s: %w", errMsg, err)
}

stackLog.Info("deploying stack")

err = docker.DeployCompose(*ctx, *dockerCli, project, deployConfig, *p)
if err != nil {
errMsg = "failed to deploy stack"
stackLog.Error(errMsg,
logger.ErrAttr(err),
slog.Group("compose_files", slog.Any("files", deployConfig.ComposeFiles)))

return fmt.Errorf("%s: %w", errMsg, err)
}

return nil
}
2 changes: 1 addition & 1 deletion cmd/doco-cd/http_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func TestHandlerData_HealthCheckHandler(t *testing.T) {
}

func TestHandlerData_WebhookHandler(t *testing.T) {
expectedResponse := `{"details":"project deployment successful","job_id":"[a-f0-9-]{36}"}`
expectedResponse := `{"details":"deployment successful","job_id":"[a-f0-9-]{36}"}`
expectedStatusCode := http.StatusCreated

payload, err := os.ReadFile(githubPayloadFile)
Expand Down
6 changes: 3 additions & 3 deletions cmd/doco-cd/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func TestHandleEvent(t *testing.T) {
Private: false,
},
expectedStatusCode: http.StatusCreated,
expectedResponseBody: `{"details":"project deployment successful","job_id":"%s"}`,
expectedResponseBody: `{"details":"deployment successful","job_id":"%s"}`,
overrideEnv: nil,
},
{
Expand Down Expand Up @@ -74,7 +74,7 @@ func TestHandleEvent(t *testing.T) {
Private: true,
},
expectedStatusCode: http.StatusCreated,
expectedResponseBody: `{"details":"project deployment successful","job_id":"%s"}`,
expectedResponseBody: `{"details":"deployment successful","job_id":"%s"}`,
overrideEnv: nil,
},
{
Expand Down Expand Up @@ -104,7 +104,7 @@ func TestHandleEvent(t *testing.T) {
Private: false,
},
expectedStatusCode: http.StatusInternalServerError,
expectedResponseBody: `{"error":"no compose files found","details":"stat ` + filepath.Join(os.TempDir(), "kimdre/kimdre/docker-compose.yaml") + `: no such file or directory","job_id":"%s"}`,
expectedResponseBody: `{"error":"no compose files found: stat ` + filepath.Join(os.TempDir(), "kimdre/kimdre/docker-compose.yaml") + `: no such file or directory","details":"deployment failed","job_id":"%[1]s"}`,
overrideEnv: nil,
},
}
Expand Down
33 changes: 18 additions & 15 deletions internal/config/deploy_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ func (c *DeployConfig) validateConfig() error {
return nil
}

// GetDeployConfig returns either the deployment configuration from the repository or the default configuration
func GetDeployConfig(repoDir, name string) (*DeployConfig, error) {
// GetDeployConfigs returns either the deployment configuration from the repository or the default configuration
func GetDeployConfigs(repoDir, name string) ([]*DeployConfig, error) {
files, err := os.ReadDir(repoDir)
if err != nil {
return nil, err
Expand All @@ -79,7 +79,7 @@ func GetDeployConfig(repoDir, name string) (*DeployConfig, error) {
DeploymentConfigFileNames := append(DefaultDeploymentConfigFileNames, DeprecatedDeploymentConfigFileNames...)

for _, configFile := range DeploymentConfigFileNames {
config, err := getDeployConfigFile(repoDir, files, configFile)
configs, err := getDeployConfigsFromFile(repoDir, files, configFile)
if err != nil {
if errors.Is(err, ErrConfigFileNotFound) {
continue
Expand All @@ -88,45 +88,48 @@ func GetDeployConfig(repoDir, name string) (*DeployConfig, error) {
}
}

if config != nil {
if err := validator.Validate(config); err != nil {
if configs != nil {
if err := validator.Validate(configs); err != nil {
return nil, err
}

// Check if the config file name is deprecated
for _, deprecatedConfigFile := range DeprecatedDeploymentConfigFileNames {
if configFile == deprecatedConfigFile {
return config, fmt.Errorf("%w: %s", ErrDeprecatedConfig, configFile)
return configs, fmt.Errorf("%w: %s", ErrDeprecatedConfig, configFile)
}
}

return config, nil
return configs, nil
}
}

return DefaultDeployConfig(name), nil
return []*DeployConfig{DefaultDeployConfig(name)}, nil
}

// getDeployConfigFile returns the deployment configuration from the repository or nil if not found
func getDeployConfigFile(dir string, files []os.DirEntry, configFile string) (*DeployConfig, error) {
// getDeployConfigsFromFile returns the deployment configurations from the repository or nil if not found
func getDeployConfigsFromFile(dir string, files []os.DirEntry, configFile string) ([]*DeployConfig, error) {
for _, f := range files {
if f.IsDir() {
continue
}

if f.Name() == configFile {
// Get contents of deploy config file
c, err := FromYAML(path.Join(dir, f.Name()))
configs, err := FromYAML(path.Join(dir, f.Name()))
if err != nil {
return nil, err
}

if err = c.validateConfig(); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
// Validate all deploy configs
for _, c := range configs {
if err = c.validateConfig(); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
}
}

if c != nil {
return c, nil
if configs != nil {
return configs, nil
}
}
}
Expand Down
Loading

0 comments on commit 3baa5df

Please sign in to comment.