Skip to content

Commit

Permalink
refactor
Browse files Browse the repository at this point in the history
  • Loading branch information
bjarneo committed Nov 28, 2024
1 parent c4e9bea commit 90eb326
Show file tree
Hide file tree
Showing 7 changed files with 694 additions and 609 deletions.
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module copepod
module github.com/bjarneo/copepod

go 1.23.1
go 1.21
196 changes: 196 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package config

import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
)

// Config holds the deployment configuration
type Config struct {
Host string `json:"host"`
User string `json:"user"`
Image string `json:"image"`
Dockerfile string `json:"dockerfile"`
Tag string `json:"tag"`
Platform string `json:"platform"`
SSHKey string `json:"sshKey"`
ContainerName string `json:"containerName"`
ContainerPort string `json:"containerPort"`
HostPort string `json:"hostPort"`
EnvFile string `json:"envFile"`
Rollback bool `json:"rollback"`
BuildArgs map[string]string `json:"buildArgs"`
Network string `json:"network"`
Volumes []string `json:"volumes"`
CPUs string `json:"cpus"`
Memory string `json:"memory"`
}

// arrayFlags allows for multiple flag values
type arrayFlags []string

func (i *arrayFlags) String() string {
return strings.Join(*i, ",")
}

func (i *arrayFlags) Set(value string) error {
*i = append(*i, value)
return nil
}

// Load loads configuration from command line flags and environment variables
func Load() Config {
var config Config
var showHelp bool
var showVersion bool
var buildArgs arrayFlags
var volumeFlags arrayFlags

// Initialize BuildArgs map
config.BuildArgs = make(map[string]string)

// Define command line flags
flag.StringVar(&config.Host, "host", getEnv("HOST", ""), "Remote host to deploy to")
flag.StringVar(&config.User, "user", getEnv("HOST_USER", ""), "SSH user for remote host")
flag.StringVar(&config.Image, "image", getEnv("DOCKER_IMAGE_NAME", "app"), "Docker image name")
flag.StringVar(&config.Dockerfile, "dockerfile", "Dockerfile", "Path to the Dockerfile")
flag.StringVar(&config.Tag, "tag", getEnv("DOCKER_IMAGE_TAG", "latest"), "Docker image tag")
flag.StringVar(&config.Platform, "platform", getEnv("HOST_PLATFORM", "linux/amd64"), "Docker platform")
flag.StringVar(&config.SSHKey, "ssh-key", getEnv("SSH_KEY_PATH", ""), "Path to SSH key")
flag.StringVar(&config.ContainerName, "container-name", getEnv("DOCKER_CONTAINER_NAME", "app"), "Name for the container")
flag.StringVar(&config.ContainerPort, "container-port", getEnv("DOCKER_CONTAINER_PORT", "3000"), "Container port")
flag.StringVar(&config.HostPort, "host-port", getEnv("HOST_PORT", "3000"), "Host port")
flag.StringVar(&config.EnvFile, "env-file", getEnv("DOCKER_CONTAINER_ENV_FILE", ""), "Environment file")
flag.Var(&buildArgs, "build-arg", "Build argument in KEY=VALUE format (can be specified multiple times)")
flag.Var(&volumeFlags, "volume", "Volume mount in format 'host:container' (can be specified multiple times)")
flag.StringVar(&config.Network, "network", getEnv("DOCKER_NETWORK", ""), "Docker network to connect to")
flag.StringVar(&config.CPUs, "cpus", getEnv("DOCKER_CPUS", ""), "Number of CPUs (e.g., '0.5' or '2')")
flag.StringVar(&config.Memory, "memory", getEnv("DOCKER_MEMORY", ""), "Memory limit (e.g., '512m' or '2g')")
flag.BoolVar(&showHelp, "help", false, "Show help message")
flag.BoolVar(&config.Rollback, "rollback", false, "Rollback to previous version")
flag.BoolVar(&showVersion, "version", false, "Show version information")

// Custom usage message
flag.Usage = func() {
fmt.Println(helpText)
}

// Parse command line flags
flag.Parse()

// Show help if requested
if showHelp {
flag.Usage()
os.Exit(0)
}

if showVersion {
fmt.Printf("Copepod version %s\n", version)
os.Exit(0)
}

// Process build arguments from command line
for _, arg := range buildArgs {
parts := strings.SplitN(arg, "=", 2)
if len(parts) == 2 {
config.BuildArgs[parts[0]] = parts[1]
}
}

// Process build arguments from environment variable
if envBuildArgs := os.Getenv("DOCKER_BUILD_ARGS"); envBuildArgs != "" {
for _, arg := range strings.Split(envBuildArgs, ",") {
parts := strings.SplitN(arg, "=", 2)
if len(parts) == 2 {
config.BuildArgs[parts[0]] = parts[1]
}
}
}

// Expand home directory in SSH key path
if strings.HasPrefix(config.SSHKey, "~/") {
home, err := os.UserHomeDir()
if err == nil {
config.SSHKey = filepath.Join(home, config.SSHKey[2:])
}
}

// Assign volume flags to config
config.Volumes = []string(volumeFlags)

return config
}

// Validate validates the configuration
func (c *Config) Validate() error {
if c.Host == "" || c.User == "" {
return fmt.Errorf("missing required configuration: host and user must be provided")
}
return nil
}

// getEnv gets an environment variable with a default value
func getEnv(key, defaultValue string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return defaultValue
}

// This will be defined on build time
var version string

const helpText = `
Docker Deployment Tool
Usage:
copepod [options]
Options:
--host Remote host to deploy to
--user SSH user for remote host
--image Docker image name (default: app)
--dockerfile Path to the dockerfile (default: Dockerfile)
--tag Docker image tag (default: latest)
--platform Docker platform (default: linux/amd64)
--ssh-key Path to SSH key (default: "")
--container-name Name for the container (default: app)
--container-port Container port (default: 3000)
--host-port Host port (default: 3000)
--env-file Environment file (default: "")
--build-arg Build arguments (can be specified multiple times, format: KEY=VALUE)
--network Docker network to connect to
--volume Volume mount (can be specified multiple times, format: host:container)
--cpus Number of CPUs (e.g., '0.5' or '2')
--memory Memory limit (e.g., '512m' or '2g')
--rollback Rollback to the previous version
--version Show version information
--help Show this help message
Environment Variables:
HOST Remote host to deploy to
HOST_USER SSH user for remote host
HOST_PORT Host port
HOST_PLATFORM Docker platform
SSH_KEY_PATH Path to SSH key
DOCKER_IMAGE_NAME Docker image name
DOCKER_IMAGE_TAG Docker image tag
DOCKER_CONTAINER_NAME Name for the container
DOCKER_CONTAINER_PORT Container port
DOCKER_BUILD_ARGS Build arguments (comma-separated KEY=VALUE pairs)
DOCKER_CONTAINER_ENV_FILE Environment file
DOCKER_NETWORK Docker network to connect to
DOCKER_CPUS Number of CPUs
DOCKER_MEMORY Memory limit
Examples:
copepod --host example.com --user deploy
copepod --host example.com --user deploy --build-arg VERSION=1.0.0 --build-arg ENV=prod
copepod --env-file .env.production --build-arg GIT_HASH=$(git rev-parse HEAD)
copepod --host example.com --user deploy --cpus "0.5" --memory "512m"
copepod --rollback # Rollback to the previous version
`
190 changes: 190 additions & 0 deletions internal/deploy/deploy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package deploy

import (
"fmt"
"strings"

"github.com/bjarneo/copepod/internal/config"
"github.com/bjarneo/copepod/internal/docker"
"github.com/bjarneo/copepod/internal/logger"
"github.com/bjarneo/copepod/internal/ssh"
)

// Deploy performs the main deployment process
func Deploy(cfg *config.Config, log *logger.Logger) error {
// Log start of deployment
if err := log.Info("Starting deployment process"); err != nil {
return err
}

// Validate configuration
if err := cfg.Validate(); err != nil {
return err
}

// Preliminary checks
if err := docker.Check(cfg, log); err != nil {
return err
}

if err := ssh.Check(cfg, log); err != nil {
return err
}

// Build Docker image
if err := docker.Build(cfg, log); err != nil {
return err
}

// Transfer Docker image
if err := docker.Transfer(cfg, log); err != nil {
return err
}

// Copy environment file if it exists
if cfg.EnvFile != "" {
if err := copyEnvFile(cfg, log); err != nil {
return err
}
}

// Deploy container
if err := docker.Deploy(cfg, log); err != nil {
return err
}

return log.Info("Deployment completed successfully! 🚀")
}

// Rollback performs a rollback to the previous version
func Rollback(cfg *config.Config, log *logger.Logger) error {
if err := log.Info("Starting rollback process..."); err != nil {
return err
}

// Validate configuration
if err := cfg.Validate(); err != nil {
return err
}

// Check SSH connection
if err := ssh.Check(cfg, log); err != nil {
return err
}

// Get current container image
getCurrentImageCmd := fmt.Sprintf("%s \"docker inspect --format='{{.Config.Image}}' %s\"",
ssh.GetCommand(cfg), cfg.ContainerName)
result, err := ssh.ExecuteCommand(log, getCurrentImageCmd, "Getting current container information")
if err != nil {
return fmt.Errorf("failed to get current container information: %v", err)
}
currentImage := strings.TrimSpace(result.Stdout)

// Get image history sorted by creation time
getImagesCmd := fmt.Sprintf("%s \"docker images %s --format '{{.Repository}}:{{.Tag}}___{{.CreatedAt}}' | sort -k2 -r\"",
ssh.GetCommand(cfg), cfg.Image)
history, err := ssh.ExecuteCommand(log, getImagesCmd, "Getting image history")
if err != nil {
return fmt.Errorf("failed to get image history: %v", err)
}

// Parse and sort images by creation time
images := strings.Split(strings.TrimSpace(history.Stdout), "\n")
if len(images) < 2 {
return fmt.Errorf("no previous version found to rollback to")
}

// Find current image and get the next one
var previousImage string
for i, img := range images {
imageName := strings.Split(img, "___")[0]
if imageName == currentImage && i+1 < len(images) {
previousImage = strings.Split(images[i+1], "___")[0]
break
}
}

if previousImage == "" {
return fmt.Errorf("could not find previous version to rollback to")
}

// Log the versions involved
if err := log.Info(fmt.Sprintf("Found previous version: %s", previousImage)); err != nil {
return err
}

if err := performRollback(cfg, log, previousImage); err != nil {
return err
}

// Clean up backup container
cleanupCmd := fmt.Sprintf("%s \"docker rm %s_backup\"", ssh.GetCommand(cfg), cfg.ContainerName)
_, _ = ssh.ExecuteCommand(log, cleanupCmd, "Cleaning up backup container")

return log.Info("Rollback completed successfully! 🔄")
}

// copyEnvFile copies the environment file to the remote host
func copyEnvFile(cfg *config.Config, log *logger.Logger) error {
copyEnvCmd := fmt.Sprintf("scp %s %s %s@%s:~/%s",
ssh.GetKeyFlag(cfg), cfg.EnvFile, cfg.User, cfg.Host, cfg.EnvFile)
_, err := ssh.ExecuteCommand(log, copyEnvCmd, "Copying environment file to server")
return err
}

// performRollback executes the rollback operation
func performRollback(cfg *config.Config, log *logger.Logger, previousImage string) error {
envFileFlag := ""
if cfg.EnvFile != "" {
envFileFlag = fmt.Sprintf("--env-file ~/%s", cfg.EnvFile)
}

rollbackCommands := strings.Join([]string{
// Stop and rename current container (for backup)
fmt.Sprintf("docker stop %s", cfg.ContainerName),
fmt.Sprintf("docker rename %s %s_backup", cfg.ContainerName, cfg.ContainerName),

// Start container with previous version
fmt.Sprintf("docker run -d --name %s --restart unless-stopped -p %s:%s %s %s",
cfg.ContainerName, cfg.HostPort, cfg.ContainerPort,
envFileFlag, previousImage),
}, " && ")

// Execute rollback
rollbackCmd := fmt.Sprintf("%s \"%s\"", ssh.GetCommand(cfg), rollbackCommands)
if _, err := ssh.ExecuteCommand(log, rollbackCmd, "Rolling back to previous version"); err != nil {
// If rollback fails, attempt to restore the backup
if restoreErr := restoreBackup(cfg, log); restoreErr != nil {
return fmt.Errorf("rollback failed and restore failed: %v (original error: %v)", restoreErr, err)
}
return fmt.Errorf("rollback failed, restored previous version: %v", err)
}

// Verify new container is running
verifyCmd := fmt.Sprintf("%s \"docker ps --filter name=%s --format '{{.Status}}'\"",
ssh.GetCommand(cfg), cfg.ContainerName)
result, err := ssh.ExecuteCommand(log, verifyCmd, "Verifying rollback container status")
if err != nil {
return err
}

if !strings.Contains(result.Stdout, "Up") {
// If verification fails, attempt to restore the backup
if restoreErr := restoreBackup(cfg, log); restoreErr != nil {
return fmt.Errorf("rollback verification failed and restore failed: %v", restoreErr)
}
return fmt.Errorf("rollback verification failed, restored previous version")
}

return nil
}

// restoreBackup attempts to restore the backup container
func restoreBackup(cfg *config.Config, log *logger.Logger) error {
restoreCmd := fmt.Sprintf("%s \"docker stop %s || true && docker rm %s || true && docker rename %s_backup %s && docker start %s\"",
ssh.GetCommand(cfg), cfg.ContainerName, cfg.ContainerName,
cfg.ContainerName, cfg.ContainerName, cfg.ContainerName)
_, err := ssh.ExecuteCommand(log, restoreCmd, "Restoring previous version after failed rollback")
return err
}
Loading

0 comments on commit 90eb326

Please sign in to comment.