diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..adf429f
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,97 @@
+name: Release Binaries
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+permissions:
+ contents: write
+ packages: write
+
+jobs:
+ build:
+ name: Build Binaries
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ include:
+ - os: linux
+ arch: amd64
+ goos: linux
+ goarch: amd64
+ - os: linux
+ arch: arm64
+ goos: linux
+ goarch: arm64
+ - os: darwin
+ arch: amd64
+ goos: darwin
+ goarch: amd64
+ - os: darwin
+ arch: arm64
+ goos: darwin
+ goarch: arm64
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.23'
+ cache: true
+
+ - name: Build binary
+ env:
+ GOOS: ${{ matrix.goos }}
+ GOARCH: ${{ matrix.goarch }}
+ run: |
+ go build -v -o copepod-${{ matrix.os }}-${{ matrix.arch }}
+ chmod +x copepod-${{ matrix.os }}-${{ matrix.arch }}
+
+ - name: Generate SHA-256
+ run: |
+ sha256sum copepod-${{ matrix.os }}-${{ matrix.arch }} > copepod-${{ matrix.os }}-${{ matrix.arch }}.sha256
+
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: binaries-${{ matrix.os }}-${{ matrix.arch }}
+ path: |
+ copepod-${{ matrix.os }}-${{ matrix.arch }}
+ copepod-${{ matrix.os }}-${{ matrix.arch }}.sha256
+ retention-days: 1
+
+ release:
+ name: Create Release
+ needs: build
+ runs-on: ubuntu-latest
+ steps:
+ - name: Download all artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: binaries-*
+ merge-multiple: true
+
+ - name: Create Release
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ # Get version from tag
+ VERSION=${GITHUB_REF#refs/tags/}
+
+ # Create release notes
+ echo "Release $VERSION" > release_notes.md
+ echo "" >> release_notes.md
+ echo "## SHA-256 Checksums" >> release_notes.md
+ echo '```' >> release_notes.md
+ cat *.sha256 >> release_notes.md
+ echo '```' >> release_notes.md
+
+ # Create release and upload assets
+ gh release create $VERSION \
+ --title "Copepod $VERSION" \
+ --notes-file release_notes.md \
+ copepod-*
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ef0e5cf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,51 @@
+# Binary
+copepod
+copepod-*
+
+# Build
+build/
+dist/
+
+# Logs
+*.log
+deploy.log
+
+# Environment files
+.env*
+!.env.example
+
+# IDE directories
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+
+# Go specific
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, built with `go test -c`
+*.test
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+
+# Go workspace file
+go.work
+
+# Dependency directories
+vendor/
+
+# OS generated files
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..142a229
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,23 @@
+# Use the official nginx image as base
+FROM nginx:alpine
+
+# Create index.html directly in the container
+RUN echo '\
+\
+
\
+ Welcome to copepod\
+\
+\
+ \
+
Welcome to Copepod!
\
+
If you see this page, the nginx web server is successfully installed and working.
\
+
This page was created from within the Dockerfile.
\
+
\
+\
+' > /usr/share/nginx/html/index.html
+
+# Expose port 80
+EXPOSE 80
+
+# Start nginx in the foreground
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..2932734
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 [Bjarne Oeverli]
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..8f266de
--- /dev/null
+++ b/README.md
@@ -0,0 +1,194 @@
+# Copepod: Docker Deployment Tool
+
+⚠️ **EXPERIMENTAL PROTOTYPE** - This tool is currently in experimental stage and should be used with caution in production environments.
+
+A simple yet powerful Go-based CLI tool for automating Docker container deployments to remote servers. This tool handles the entire deployment process including building Docker images, transferring them to remote hosts, and managing container lifecycle.
+
+## Prerequisites
+
+- Docker installed locally and on the remote host
+- SSH access to the remote host
+- SSH key-based authentication
+
+## Installation
+
+### Pre-built Binaries
+
+Download the latest pre-built binary from the [releases page](https://github.com/bjarneo/copepod/releases).
+
+Available binaries:
+
+- Linux (AMD64): `copepod-linux-amd64`
+- Linux (ARM64): `copepod-linux-arm64`
+- macOS (Intel): `copepod-darwin-amd64`
+- macOS (Apple Silicon): `copepod-darwin-arm64`
+
+After downloading:
+
+1. Verify the checksum (SHA-256):
+
+```bash
+sha256sum -c copepod--.sha256
+```
+
+2. Make the binary executable:
+
+```bash
+chmod +x copepod--
+```
+
+3. Optionally, move to your PATH:
+
+```bash
+# Example for Linux/macOS
+sudo mv copepod-- /usr/local/bin/copepod
+```
+
+### Building from Source
+
+Alternatively, you can build from source:
+
+Requirements:
+
+- Go 1.x or higher
+
+```bash
+git clone
+cd copepod
+go build -o copepod
+```
+
+## Usage
+
+```bash
+./copepod [options]
+```
+
+### Command Line Options
+
+| Option | Environment Variable | Default | Description |
+|-----------------|---------------------|------------------|--------------------------------|
+| --host | COPEPOD_HOST | | Remote host to deploy to |
+| --user | COPEPOD_USER | | SSH user for remote host |
+| --image | COPEPOD_IMAGE | app | Docker image name |
+| --tag | COPEPOD_TAG | latest | Docker image tag |
+| --platform | COPEPOD_PLATFORM | linux/amd64 | Docker platform |
+| --ssh-key | SSH_KEY_PATH | | Path to SSH key |
+| --container-name| CONTAINER_NAME | app | Name for the container |
+| --container-port| CONTAINER_PORT | 3000 | Container port |
+| --host-port | HOST_PORT | 3000 | Host port |
+| --env-file | ENV_FILE | .env.production | Environment file |
+
+### Example Commands
+
+Basic deployment:
+
+```bash
+./copepod --host example.com --user deploy
+```
+
+Deployment with custom ports:
+
+```bash
+./copepod --host example.com --user deploy --container-name myapp --container-port 8080 --host-port 80
+```
+
+Using environment file:
+
+```bash
+./copepod --env-file .env.production
+```
+
+## Directory Structure
+
+Your project directory should look like this:
+
+```
+.
+├── Dockerfile # Required: Docker build instructions
+├── your_code # Your code
+└── .env.production # Optional: Environment variables
+```
+
+## Deployment Process
+
+1. Validates configuration and checks prerequisites
+2. Verifies Docker installation and SSH connectivity
+3. Builds Docker image locally
+4. Transfers image to remote host
+5. Copies environment file (if specified)
+6. Stops and removes existing container
+7. Starts new container with specified configuration
+8. Verifies container is running properly
+
+## Logging
+
+The tool maintains detailed logs in `deploy.log`, including:
+
+- Timestamp for each operation
+- Command execution details
+- Success/failure status
+- Error messages and stack traces
+
+## Error Handling
+
+The tool includes error handling for common scenarios:
+
+- Missing Dockerfile
+- SSH connection failures
+- Docker build/deployment errors
+- Container startup issues
+
+## Security Considerations
+
+- Uses SSH key-based authentication
+- Supports custom SSH key paths
+- Environment variables can be passed securely via env file
+- No sensitive information is logged
+
+## Known Limitations
+
+1. Limited error recovery mechanisms
+2. No rollback functionality
+3. Basic container health checking
+4. No support for complex Docker network configurations
+5. No Docker Compose support
+
+## Contributing
+
+1. Fork the repository
+2. Create your feature branch
+3. Commit your changes
+4. Push to the branch
+5. Create a new Pull Request
+
+## Release Process
+
+New versions are automatically built and released when a new tag is pushed:
+
+```bash
+git tag v1.0.0
+git push origin v1.0.0
+```
+
+This will trigger the GitHub Action workflow to:
+
+1. Build binaries for multiple platforms
+2. Generate checksums
+3. Create a new release with the binaries
+
+## TODO
+
+- [ ] Add rollback functionality
+- [ ] Improve error handling
+- [ ] Add support for Docker Compose
+- [ ] Implement proper container health checks
+- [ ] Add shell completion support
+
+## License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+
+## Disclaimer
+
+This is an experimental prototype. Use at your own risk. The authors assume no liability for the use of this tool. Always review the code and test in a safe environment before using in any critical systems.
diff --git a/demo.mp4 b/demo.mp4
new file mode 100644
index 0000000..b248f8f
Binary files /dev/null and b/demo.mp4 differ
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..1a6ea9c
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module copepod
+
+go 1.23.1
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..0a48423
--- /dev/null
+++ b/main.go
@@ -0,0 +1,341 @@
+package main
+
+import (
+ "encoding/json"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// Config holds the deployment configuration
+type Config struct {
+ Host string `json:"host"`
+ User string `json:"user"`
+ Image string `json:"image"`
+ 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"`
+}
+
+// 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:
+ deploy [options]
+
+Options:
+ --host Remote host to deploy to
+ --user SSH user for remote host
+ --image Docker image name (default: app)
+ --tag Docker image tag (default: latest)
+ --platform Docker platform (default: linux/amd64)
+ --ssh-key Path to SSH key (default: ~/.ssh/id_rsa)
+ --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: .env.production)
+ --help Show this help message
+
+Environment Variables:
+ DEPLOY_HOST Remote host to deploy to
+ DEPLOY_USER SSH user for remote host
+ DEPLOY_IMAGE Docker image name
+ DEPLOY_TAG Docker image tag
+ DEPLOY_PLATFORM Docker platform
+ SSH_KEY_PATH Path to SSH key
+ CONTAINER_NAME Name for the container
+ CONTAINER_PORT Container port
+ HOST_PORT Host port
+ ENV_FILE Environment file
+
+Examples:
+ deploy --host example.com --user deploy
+ deploy --host example.com --user deploy --container-name myapp --container-port 8080
+ deploy --env-file .env.production
+`
+
+// 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()
+}
+
+// LoadConfig loads configuration from command line flags and environment variables
+func LoadConfig() Config {
+ var config Config
+ var showHelp bool
+
+ // Define command line flags
+ flag.StringVar(&config.Host, "host", getEnv("DEPLOY_HOST", ""), "Remote host to deploy to")
+ flag.StringVar(&config.User, "user", getEnv("DEPLOY_USER", ""), "SSH user for remote host")
+ flag.StringVar(&config.Image, "image", getEnv("DEPLOY_IMAGE", "app"), "Docker image name")
+ flag.StringVar(&config.Tag, "tag", getEnv("DEPLOY_TAG", "latest"), "Docker image tag")
+ flag.StringVar(&config.Platform, "platform", getEnv("DEPLOY_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("CONTAINER_NAME", "app"), "Name for the container")
+ flag.StringVar(&config.ContainerPort, "container-port", getEnv("CONTAINER_PORT", "3000"), "Container port")
+ flag.StringVar(&config.HostPort, "host-port", getEnv("HOST_PORT", "3000"), "Host port")
+ flag.StringVar(&config.EnvFile, "env-file", getEnv("ENV_FILE", ".env.production"), "Environment file")
+ flag.BoolVar(&showHelp, "help", false, "Show help message")
+
+ // 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)
+ }
+
+ // 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:])
+ }
+ }
+
+ 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 logs 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)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ logger.Error(fmt.Sprintf("Failed: %s", description), err)
+ return nil, err
+ }
+
+ result := &CommandResult{
+ Stdout: string(output),
+ Stderr: "",
+ }
+
+ if len(output) > 0 {
+ logger.Info(fmt.Sprintf("Output: %s", string(output)))
+ }
+
+ return result, nil
+}
+
+// CheckDocker checks if Docker is installed and running
+func CheckDocker(logger *Logger) error {
+ _, err := ExecuteCommand(logger, "docker info", "Checking Docker installation")
+ return err
+}
+
+// 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
+}
+
+// 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(logger); err != nil {
+ return err
+ }
+
+ if err := CheckSSH(config, logger); err != nil {
+ return err
+ }
+
+ // Check if Dockerfile exists
+ if _, err := os.Stat("Dockerfile"); os.IsNotExist(err) {
+ return fmt.Errorf("Dockerfile not found in current directory")
+ }
+
+ // Build Docker image
+ buildCmd := fmt.Sprintf("docker build --platform %s -t %s:%s .",
+ config.Platform, 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
+ }
+ }
+
+ // Prepare remote commands
+ envFileFlag := ""
+ if _, err := os.Stat(config.EnvFile); err == nil {
+ envFileFlag = fmt.Sprintf("--env-file ~/%s", config.EnvFile)
+ }
+
+ remoteCommands := strings.Join([]string{
+ fmt.Sprintf("docker stop %s || true", config.ContainerName),
+ fmt.Sprintf("docker rm %s || true", config.ContainerName),
+ fmt.Sprintf("docker run -d --name %s --restart unless-stopped -p %s:%s %s %s:%s",
+ config.ContainerName, config.HostPort, config.ContainerPort,
+ envFileFlag, config.Image, config.Tag),
+ }, " && ")
+
+ // 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")
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer logger.Close()
+
+ config := LoadConfig()
+ if err := Deploy(&config, logger); err != nil {
+ logger.Error("Deployment failed", err)
+ os.Exit(1)
+ }
+}