-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
7 changed files
with
694 additions
and
609 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Oops, something went wrong.