From 90eb3260f8805473ae973788124e829ce92bcd12 Mon Sep 17 00:00:00 2001 From: bjarneo Date: Thu, 28 Nov 2024 21:02:32 +0100 Subject: [PATCH] refactor --- go.mod | 4 +- internal/config/config.go | 196 ++++++++++++ internal/deploy/deploy.go | 190 ++++++++++++ internal/docker/docker.go | 118 +++++++ internal/logger/logger.go | 61 ++++ internal/ssh/ssh.go | 108 +++++++ main.go | 626 ++------------------------------------ 7 files changed, 694 insertions(+), 609 deletions(-) create mode 100644 internal/config/config.go create mode 100644 internal/deploy/deploy.go create mode 100644 internal/docker/docker.go create mode 100644 internal/logger/logger.go create mode 100644 internal/ssh/ssh.go diff --git a/go.mod b/go.mod index 1a6ea9c..2f75d34 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module copepod +module github.com/bjarneo/copepod -go 1.23.1 +go 1.21 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..867db44 --- /dev/null +++ b/internal/config/config.go @@ -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 +` \ No newline at end of file diff --git a/internal/deploy/deploy.go b/internal/deploy/deploy.go new file mode 100644 index 0000000..8e985d4 --- /dev/null +++ b/internal/deploy/deploy.go @@ -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 +} \ No newline at end of file diff --git a/internal/docker/docker.go b/internal/docker/docker.go new file mode 100644 index 0000000..5eb775b --- /dev/null +++ b/internal/docker/docker.go @@ -0,0 +1,118 @@ +package docker + +import ( + "fmt" + "os" + "strings" + + "github.com/bjarneo/copepod/internal/config" + "github.com/bjarneo/copepod/internal/logger" + "github.com/bjarneo/copepod/internal/ssh" +) + +// Check checks if Docker is installed and running locally and remotely +func Check(cfg *config.Config, log *logger.Logger) error { + // Check local Docker + if _, err := ssh.ExecuteCommand(log, "docker info", "Checking local Docker installation"); err != nil { + return fmt.Errorf("local Docker check failed: %v", err) + } + + // Check remote Docker + remoteCmd := fmt.Sprintf("%s \"docker info\"", ssh.GetCommand(cfg)) + if _, err := ssh.ExecuteCommand(log, remoteCmd, "Checking remote Docker installation"); err != nil { + return fmt.Errorf("remote Docker check failed - please ensure Docker is installed on %s: %v", cfg.Host, err) + } + + return nil +} + +// Build builds the Docker image +func Build(cfg *config.Config, log *logger.Logger) error { + // Check if Dockerfile exists + if _, err := os.Stat(cfg.Dockerfile); os.IsNotExist(err) { + return fmt.Errorf("%s not found", cfg.Dockerfile) + } + + // Build Docker image with build arguments + buildCmd := fmt.Sprintf("docker build --platform %s", cfg.Platform) + + // Add build arguments to the command + for key, value := range cfg.BuildArgs { + buildCmd += fmt.Sprintf(" --build-arg %s=%s", key, value) + } + + buildCmd += fmt.Sprintf(" -t %s:%s .", cfg.Image, cfg.Tag) + + _, err := ssh.ExecuteCommand(log, buildCmd, "Building Docker image") + return err +} + +// Transfer transfers the Docker image to the remote host +func Transfer(cfg *config.Config, log *logger.Logger) error { + deployCmd := fmt.Sprintf("docker save %s:%s | gzip | %s docker load", + cfg.Image, cfg.Tag, ssh.GetCommand(cfg)) + _, err := ssh.ExecuteCommand(log, deployCmd, "Transferring Docker image to server") + return err +} + +// Deploy deploys the container on the remote host +func Deploy(cfg *config.Config, log *logger.Logger) error { + containerConfig := []string{ + "-d", + "--name", cfg.ContainerName, + "--restart", "unless-stopped", + "-p", fmt.Sprintf("%s:%s", cfg.HostPort, cfg.ContainerPort), + } + + if cfg.Network != "" { + containerConfig = append(containerConfig, "--network", cfg.Network) + } + + if cfg.CPUs != "" { + containerConfig = append(containerConfig, "--cpus", cfg.CPUs) + } + + if cfg.Memory != "" { + containerConfig = append(containerConfig, "--memory", cfg.Memory) + } + + for _, volume := range cfg.Volumes { + containerConfig = append(containerConfig, "-v", volume) + } + + if cfg.EnvFile != "" { + containerConfig = append(containerConfig, fmt.Sprintf("--env-file ~/%s", cfg.EnvFile)) + } + + containerConfig = append(containerConfig, fmt.Sprintf("%s:%s", cfg.Image, cfg.Tag)) + + remoteCommands := strings.Join([]string{ + fmt.Sprintf("docker stop %s || true", cfg.ContainerName), + fmt.Sprintf("docker rm %s || true", cfg.ContainerName), + fmt.Sprintf("docker run %s", strings.Join(containerConfig, " ")), + }, " && ") + + // Execute remote commands + restartCmd := fmt.Sprintf("%s \"%s\"", ssh.GetCommand(cfg), remoteCommands) + if _, err := ssh.ExecuteCommand(log, restartCmd, "Restarting container on server"); err != nil { + return err + } + + return verifyContainer(cfg, log) +} + +// verifyContainer verifies that the container is running +func verifyContainer(cfg *config.Config, log *logger.Logger) error { + verifyCmd := fmt.Sprintf("%s \"docker ps --filter name=%s --format '{{.Status}}'\"", + ssh.GetCommand(cfg), cfg.ContainerName) + result, err := ssh.ExecuteCommand(log, verifyCmd, "Verifying container status") + if err != nil { + return err + } + + if !strings.Contains(result.Stdout, "Up") { + return fmt.Errorf("container failed to start properly") + } + + return nil +} \ No newline at end of file diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..bcd20cf --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,61 @@ +package logger + +import ( + "fmt" + "os" + "time" +) + +// Logger handles logging to both console and file +type Logger struct { + file *os.File +} + +// New creates a new logger instance +func New(filename string) (*Logger, error) { + file, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil, err + } + return &Logger{file: file}, nil +} + +// Info logs an informational message +func (l *Logger) Info(message string) error { + timestamp := time.Now().UTC().Format(time.RFC3339) + logMessage := fmt.Sprintf("[%s] INFO: %s\n", timestamp, message) + fmt.Print(message + "\n") + _, err := l.file.WriteString(logMessage) + return err +} + +// Error logs an error message +func (l *Logger) Error(message string, err error) error { + timestamp := time.Now().UTC().Format(time.RFC3339) + errStr := "" + if err != nil { + errStr = err.Error() + } + logMessage := fmt.Sprintf("[%s] ERROR: %s\n%s\n", timestamp, message, errStr) + fmt.Printf("ERROR: %s\n", message) + if err != nil { + fmt.Printf("Error details: %s\n", err) + } + _, writeErr := l.file.WriteString(logMessage) + return writeErr +} + +// Fatal logs a fatal error message and exits the program +func (l *Logger) Fatal(err error) { + timestamp := time.Now().UTC().Format(time.RFC3339) + logMessage := fmt.Sprintf("[%s] FATAL: %s\n", timestamp, err.Error()) + fmt.Printf("FATAL: %s\n", err) + l.file.WriteString(logMessage) + l.Close() + os.Exit(1) +} + +// Close closes the log file +func (l *Logger) Close() error { + return l.file.Close() +} \ No newline at end of file diff --git a/internal/ssh/ssh.go b/internal/ssh/ssh.go new file mode 100644 index 0000000..c153fa5 --- /dev/null +++ b/internal/ssh/ssh.go @@ -0,0 +1,108 @@ +package ssh + +import ( + "bufio" + "fmt" + "os/exec" + "strings" + + "github.com/bjarneo/copepod/internal/config" + "github.com/bjarneo/copepod/internal/logger" +) + +// CommandResult contains the output of a command +type CommandResult struct { + Stdout string + Stderr string +} + +// GetKeyFlag returns the SSH key flag if SSHKey is set +func GetKeyFlag(cfg *config.Config) string { + if cfg.SSHKey != "" { + return fmt.Sprintf("-i %s", cfg.SSHKey) + } + return "" +} + +// GetCommand returns the full SSH command with or without the key flag +func GetCommand(cfg *config.Config) string { + sshKeyFlag := GetKeyFlag(cfg) + if sshKeyFlag != "" { + return fmt.Sprintf("ssh %s %s@%s", sshKeyFlag, cfg.User, cfg.Host) + } + return fmt.Sprintf("ssh %s@%s", cfg.User, cfg.Host) +} + +// Check checks SSH connection to the remote host +func Check(cfg *config.Config, log *logger.Logger) error { + command := fmt.Sprintf("%s echo \"SSH connection successful\"", GetCommand(cfg)) + _, err := ExecuteCommand(log, command, "Checking SSH connection") + return err +} + +// ExecuteCommand executes a shell command and streams the output +func ExecuteCommand(log *logger.Logger, command string, description string) (*CommandResult, error) { + if err := log.Info(fmt.Sprintf("%s...", description)); err != nil { + return nil, err + } + if err := log.Info(fmt.Sprintf("Executing: %s", command)); err != nil { + return nil, err + } + + cmd := exec.Command("sh", "-c", command) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("failed to create stdout pipe: %v", err) + } + + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("failed to create stderr pipe: %v", err) + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("failed to start command: %v", err) + } + + var stdoutBuilder, stderrBuilder strings.Builder + + // Read stdout in real-time + go func() { + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + fmt.Println(line) + stdoutBuilder.WriteString(line + "\n") + } + }() + + // Read stderr in real-time + go func() { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "error") || strings.Contains(line, "Error") { + fmt.Println("ERROR:", line) + stderrBuilder.WriteString(line + "\n") + } else { + fmt.Println(line) + stdoutBuilder.WriteString(line + "\n") + } + } + }() + + if err := cmd.Wait(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() != 0 { + return nil, fmt.Errorf("command failed with exit code %d: %v", exitErr.ExitCode(), err) + } + return nil, fmt.Errorf("command failed: %v", err) + } + + result := &CommandResult{ + Stdout: stdoutBuilder.String(), + Stderr: stderrBuilder.String(), + } + + return result, nil +} \ No newline at end of file diff --git a/main.go b/main.go index c427c63..4978bba 100644 --- a/main.go +++ b/main.go @@ -1,624 +1,36 @@ package main import ( - "bufio" - "encoding/json" - "flag" - "fmt" - "log" "os" - "os/exec" - "path/filepath" - "strings" - "time" -) - -// This will be defined on build time -var version string - -// 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"` -} - -// Logger handles logging to both console and file -type Logger struct { - file *os.File -} - -// OSRelease contains OS information -type OSRelease struct { - ID string `json:"id"` - VersionID string `json:"version_id"` - PrettyName string `json:"pretty_name"` -} - -// CommandResult contains the output of a command -type CommandResult struct { - Stdout string - Stderr 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 -` - -// NewLogger creates a new logger instance -func NewLogger(filename string) (*Logger, error) { - file, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return nil, err - } - return &Logger{file: file}, nil -} - -// Info logs an informational message -func (l *Logger) Info(message string) error { - timestamp := time.Now().UTC().Format(time.RFC3339) - logMessage := fmt.Sprintf("[%s] INFO: %s\n", timestamp, message) - fmt.Print(message + "\n") - _, err := l.file.WriteString(logMessage) - return err -} - -// Error logs an error message -func (l *Logger) Error(message string, err error) error { - timestamp := time.Now().UTC().Format(time.RFC3339) - errStr := "" - if err != nil { - errStr = err.Error() - } - logMessage := fmt.Sprintf("[%s] ERROR: %s\n%s\n", timestamp, message, errStr) - fmt.Printf("ERROR: %s\n", message) - if err != nil { - fmt.Printf("Error details: %s\n", err) - } - _, writeErr := l.file.WriteString(logMessage) - return writeErr -} - -// Close closes the log file -func (l *Logger) Close() error { - return l.file.Close() -} - -// 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 -} - -// LoadConfig loads configuration from command line flags and environment variables -func LoadConfig() 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 -} - -// 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 -} - -// ValidateConfig validates the configuration -func (c *Config) ValidateConfig() error { - if c.Host == "" || c.User == "" { - return fmt.Errorf("missing required configuration: host and user must be provided") - } - return nil -} - -// ExecuteCommand executes a shell command and streams the output -func ExecuteCommand(logger *Logger, command string, description string) (*CommandResult, error) { - if err := logger.Info(fmt.Sprintf("%s...", description)); err != nil { - return nil, err - } - if err := logger.Info(fmt.Sprintf("Executing: %s", command)); err != nil { - return nil, err - } - - cmd := exec.Command("sh", "-c", command) - - stdout, err := cmd.StdoutPipe() - if err != nil { - return nil, fmt.Errorf("failed to create stdout pipe: %v", err) - } - - stderr, err := cmd.StderrPipe() - if err != nil { - return nil, fmt.Errorf("failed to create stderr pipe: %v", err) - } - - if err := cmd.Start(); err != nil { - return nil, fmt.Errorf("failed to start command: %v", err) - } - - var stdoutBuilder, stderrBuilder strings.Builder - - // Read stdout in real-time - go func() { - scanner := bufio.NewScanner(stdout) - for scanner.Scan() { - line := scanner.Text() - fmt.Println(line) - stdoutBuilder.WriteString(line + "\n") - } - }() - - // Read stderr in real-time - go func() { - scanner := bufio.NewScanner(stderr) - for scanner.Scan() { - line := scanner.Text() - if strings.Contains(line, "error") || strings.Contains(line, "Error") { - fmt.Println("ERROR:", line) - stderrBuilder.WriteString(line + "\n") - } else { - fmt.Println(line) - stdoutBuilder.WriteString(line + "\n") - } - } - }() - - if err := cmd.Wait(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() != 0 { - return nil, fmt.Errorf("command failed with exit code %d: %v", exitErr.ExitCode(), err) - } - return nil, fmt.Errorf("command failed: %v", err) - } - - result := &CommandResult{ - Stdout: stdoutBuilder.String(), - Stderr: stderrBuilder.String(), - } - - return result, nil -} - -// CheckDocker checks if Docker is installed and running locally and remotely -func CheckDocker(config *Config, logger *Logger) error { - // Check local Docker - if _, err := ExecuteCommand(logger, "docker info", "Checking local Docker installation"); err != nil { - return fmt.Errorf("local Docker check failed: %v", err) - } - - // Check remote Docker - remoteCmd := fmt.Sprintf("%s \"docker info\"", getSSHCommand(config)) - if _, err := ExecuteCommand(logger, remoteCmd, "Checking remote Docker installation"); err != nil { - return fmt.Errorf("remote Docker check failed - please ensure Docker is installed on %s: %v", config.Host, err) - } - - return nil -} - -// getSSHKeyFlag returns the SSH key flag if SSHKey is set -func getSSHKeyFlag(config *Config) string { - if config.SSHKey != "" { - return fmt.Sprintf("-i %s", config.SSHKey) - } - return "" -} -// getSSHCommand returns the full SSH command with or without the key flag -func getSSHCommand(config *Config) string { - sshKeyFlag := getSSHKeyFlag(config) - if sshKeyFlag != "" { - return fmt.Sprintf("ssh %s %s@%s", sshKeyFlag, config.User, config.Host) - } - return fmt.Sprintf("ssh %s@%s", config.User, config.Host) -} - -// CheckSSH checks SSH connection to the remote host -func CheckSSH(config *Config, logger *Logger) error { - command := fmt.Sprintf("%s echo \"SSH connection successful\"", getSSHCommand(config)) - _, err := ExecuteCommand(logger, command, "Checking SSH connection") - return err -} - -// Rollback performs a rollback to the previous version by inspecting the current container -// TODO: fix this mess -func Rollback(config *Config, logger *Logger) error { - if err := logger.Info("Starting rollback process..."); err != nil { - return err - } - - // Validate configuration - if err := config.ValidateConfig(); err != nil { - return err - } - - // Check SSH connection - if err := CheckSSH(config, logger); err != nil { - return err - } - - // Get current container image - getCurrentImageCmd := fmt.Sprintf("%s \"docker inspect --format='{{.Config.Image}}' %s\"", - getSSHCommand(config), config.ContainerName) - result, err := ExecuteCommand(logger, 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 - // Format: repository, tag, image ID, created, size - getImagesCmd := fmt.Sprintf("%s \"docker images %s --format '{{.Repository}}:{{.Tag}}___{{.CreatedAt}}' | sort -k2 -r\"", - getSSHCommand(config), config.Image) - history, err := ExecuteCommand(logger, 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 := logger.Info(fmt.Sprintf("Found previous version: %s", previousImage)); err != nil { - return err - } - - // Prepare rollback commands - envFileFlag := "" - if config.EnvFile != "" { - envFileFlag = fmt.Sprintf("--env-file ~/%s", config.EnvFile) - } - - rollbackCommands := strings.Join([]string{ - // Stop and rename current container (for backup) - fmt.Sprintf("docker stop %s", config.ContainerName), - fmt.Sprintf("docker rename %s %s_backup", config.ContainerName, config.ContainerName), - - // Start container with previous version - fmt.Sprintf("docker run -d --name %s --restart unless-stopped -p %s:%s %s %s", - config.ContainerName, config.HostPort, config.ContainerPort, - envFileFlag, previousImage), - }, " && ") + "github.com/bjarneo/copepod/internal/config" + "github.com/bjarneo/copepod/internal/deploy" + "github.com/bjarneo/copepod/internal/logger" +) - // Execute rollback - rollbackCmd := fmt.Sprintf("%s \"%s\"", getSSHCommand(config), rollbackCommands) - if _, err := ExecuteCommand(logger, rollbackCmd, "Rolling back to previous version"); err != nil { - // If rollback fails, attempt to restore the backup - restoreCmd := fmt.Sprintf("%s \"docker stop %s || true && docker rm %s || true && docker rename %s_backup %s && docker start %s\"", - getSSHCommand(config), config.ContainerName, config.ContainerName, - config.ContainerName, config.ContainerName, config.ContainerName) - if _, restoreErr := ExecuteCommand(logger, restoreCmd, "Restoring previous version after failed rollback"); 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) - } +func main() { + log := initLogger() + defer log.Close() - // Verify new container is running - verifyCmd := fmt.Sprintf("%s \"docker ps --filter name=%s --format '{{.Status}}'\"", - getSSHCommand(config), config.ContainerName) - result, err = ExecuteCommand(logger, verifyCmd, "Verifying rollback container status") - if err != nil { - return err - } + cfg := config.Load() - if !strings.Contains(result.Stdout, "Up") { - // If verification fails, attempt to restore the backup - restoreCmd := fmt.Sprintf("%s \"docker stop %s || true && docker rm %s || true && docker rename %s_backup %s && docker start %s\"", - getSSHCommand(config), config.ContainerName, config.ContainerName, - config.ContainerName, config.ContainerName, config.ContainerName) - if _, restoreErr := ExecuteCommand(logger, restoreCmd, "Restoring previous version after failed verification"); restoreErr != nil { - return fmt.Errorf("rollback verification failed and restore failed: %v", restoreErr) + if cfg.Rollback { + if err := deploy.Rollback(&cfg, log); err != nil { + log.Error("Rollback failed", err) + os.Exit(1) } - return fmt.Errorf("rollback verification failed, restored previous version") - } - - // Log the rollback details - logger.Info(fmt.Sprintf("Successfully rolled back from %s to %s", currentImage, previousImage)) - - // If everything is successful, remove the backup container - cleanupCmd := fmt.Sprintf("%s \"docker rm %s_backup\"", getSSHCommand(config), config.ContainerName) - _, _ = ExecuteCommand(logger, cleanupCmd, "Cleaning up backup container") - - return logger.Info("Rollback completed successfully! 🔄") -} - -// Deploy performs the main deployment process -func Deploy(config *Config, logger *Logger) error { - // Log start of deployment - if err := logger.Info("Starting deployment process"); err != nil { - return err - } - - configJSON, _ := json.MarshalIndent(config, "", " ") - if err := logger.Info(fmt.Sprintf("Deployment configuration: %s", string(configJSON))); err != nil { - return err - } - - // Validate configuration - if err := config.ValidateConfig(); err != nil { - return err - } - - // Preliminary checks - if err := CheckDocker(config, logger); err != nil { - return err - } - - if err := CheckSSH(config, logger); err != nil { - return err - } - - // Check if Dockerfile exists - if _, err := os.Stat(config.Dockerfile); os.IsNotExist(err) { - return fmt.Errorf("%s not found", config.Dockerfile) - } - - // Build Docker image with build arguments - buildCmd := fmt.Sprintf("docker build --platform %s", config.Platform) - - // Add build arguments to the command - for key, value := range config.BuildArgs { - buildCmd += fmt.Sprintf(" --build-arg %s=%s", key, value) - } - - buildCmd += fmt.Sprintf(" -t %s:%s .", config.Image, config.Tag) - - if _, err := ExecuteCommand(logger, buildCmd, "Building Docker image"); err != nil { - return err - } - - // Save and transfer Docker image - deployCmd := fmt.Sprintf("docker save %s:%s | gzip | %s docker load", - config.Image, config.Tag, getSSHCommand(config)) - if _, err := ExecuteCommand(logger, deployCmd, "Transferring Docker image to server"); err != nil { - return err - } - - // Copy environment file if it exists - if _, err := os.Stat(config.EnvFile); err == nil { - copyEnvCmd := fmt.Sprintf("scp %s %s %s@%s:~/%s", - getSSHKeyFlag(config), config.EnvFile, config.User, config.Host, config.EnvFile) - if _, err := ExecuteCommand(logger, copyEnvCmd, "Copying environment file to server"); err != nil { - return err + } else { + if err := deploy.Deploy(&cfg, log); err != nil { + log.Error("Deployment failed", err) + os.Exit(1) } } - - // Prepare container configuration - containerConfig := []string{ - "-d", - "--name", config.ContainerName, - "--restart", "unless-stopped", - "-p", fmt.Sprintf("%s:%s", config.HostPort, config.ContainerPort), - } - - if config.Network != "" { - containerConfig = append(containerConfig, "--network", config.Network) - } - - if config.CPUs != "" { - containerConfig = append(containerConfig, "--cpus", config.CPUs) - } - - if config.Memory != "" { - containerConfig = append(containerConfig, "--memory", config.Memory) - } - - for _, volume := range config.Volumes { - containerConfig = append(containerConfig, "-v", volume) - } - - if config.EnvFile != "" { - containerConfig = append(containerConfig, fmt.Sprintf("--env-file ~/%s", config.EnvFile)) - } - - containerConfig = append(containerConfig, fmt.Sprintf("%s:%s", config.Image, config.Tag)) - - remoteCommands := strings.Join([]string{ - fmt.Sprintf("docker stop %s || true", config.ContainerName), - fmt.Sprintf("docker rm %s || true", config.ContainerName), - fmt.Sprintf("docker run %s", strings.Join(containerConfig, " ")), - }, " && ") - - // Execute remote commands - restartCmd := fmt.Sprintf("%s \"%s\"", getSSHCommand(config), remoteCommands) - if _, err := ExecuteCommand(logger, restartCmd, "Restarting container on server"); err != nil { - return err - } - - // Verify container is running - verifyCmd := fmt.Sprintf("%s \"docker ps --filter name=%s --format '{{.Status}}'\"", - getSSHCommand(config), config.ContainerName) - result, err := ExecuteCommand(logger, verifyCmd, "Verifying container status") - if err != nil { - return err - } - - if !strings.Contains(result.Stdout, "Up") { - return fmt.Errorf("container failed to start properly") - } - - return logger.Info("Deployment completed successfully! 🚀") } -func main() { - logger, err := NewLogger("deploy.log") +func initLogger() *logger.Logger { + log, err := logger.New("deploy.log") if err != nil { log.Fatal(err) } - defer logger.Close() - - config := LoadConfig() - - if config.Rollback { - if err := Rollback(&config, logger); err != nil { - logger.Error("Rollback failed", err) - os.Exit(1) - } - } else { - if err := Deploy(&config, logger); err != nil { - logger.Error("Deployment failed", err) - os.Exit(1) - } - } + return log }